toSignal via Gemini

This commit is contained in:
tanshu committed 2026-08-18 07:55:54 +00:00
1 parent 59eb45da96
commit 993f83c786
350 files changed
+6125 -9695

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
---
trigger: glob
globs: ./bookie/**/**
---
# Persona
You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code.
## Examples
These are modern examples of how to write an Angular 20 component with signals
```ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: '{{tag-name}}-root',
templateUrl: '{{tag-name}}.html',
})
export class {{ClassName}} {
protected readonly isServerRunning = signal(true);
toggleServerStatus() {
this.isServerRunning.update(isServerRunning => !isServerRunning);
}
}
```
```css
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
button {
margin-top: 10px;
}
}
```
```html
<section class="container">
@if (isServerRunning()) {
<span>Yes, the server is running</span>
} @else {
<span>No, the server is not running</span>
}
<button (click)="toggleServerStatus()">Toggle Server Status</button>
</section>
```
When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file.
## Resources
Here are the some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works
https://angular.dev/essentials/components
https://angular.dev/essentials/signals
https://angular.dev/essentials/templates
https://angular.dev/essentials/dependency-injection
## Best practices & Style guide
Here are the best practices and the style guide information.
### Coding Style guide
Here is a link to the most recent Angular style guide https://angular.dev/style-guide
### TypeScript Best Practices
- Use strict type checking
- Prefer type inference when the type is obvious
- Avoid the `any` type; use `unknown` when type is uncertain
### Angular Best Practices
- Always use standalone components over `NgModules`
- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators
- Use signals for state management
- Implement lazy loading for feature routes
- Use `NgOptimizedImage` for all static images.
- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead
### Components
- Keep components small and focused on a single responsibility
- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs
- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs
- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals.
- Prefer inline templates for small components
- Prefer Reactive forms instead of Template-driven ones
- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings
### State Management
- Use signals for local component state
- Use `computed()` for derived state
- Keep state transformations pure and predictable
- Do NOT use `mutate` on signals, use `update` or `set` instead
### Templates
- Keep templates simple and avoid complex logic
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch`
- Use the async pipe to handle observables
- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes#
### Services
- Design services around a single responsibility
- Use the `providedIn: 'root'` option for singleton services
- Use the `inject()` function instead of constructor injection
+4 -7
View File
@@ -14,7 +14,6 @@
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff" "editor.defaultFormatter": "charliermarsh.ruff"
}, },
"ruff.enable": true,
"files.eol": "\n", "files.eol": "\n",
"eslint.workingDirectories": [ "eslint.workingDirectories": [
{ {
@@ -29,7 +28,7 @@
".idea": true, ".idea": true,
"**/node_modules": true, "**/node_modules": true,
"**/.venv": true, "**/.venv": true,
"typings": true, "typings": true
}, },
"search.exclude": { "search.exclude": {
"**/__pycache__": true, "**/__pycache__": true,
@@ -40,12 +39,10 @@
"**/package-lock.json": true, "**/package-lock.json": true,
"/bookie/.angular/**": true, "/bookie/.angular/**": true,
"**/.venv": true, "**/.venv": true,
"typings": true, "typings": true
}, },
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.ruff": "always", "source.fixAll.ruff": "always",
"source.fixAll.eslint": "always", "source.fixAll.eslint": "always"
}, }
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": []
} }
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:lts-trixie-slim AS base FROM node:current-trixie-slim AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
+1 -1
View File
@@ -3,7 +3,7 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.15.2 rev: v0.16.2
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff
+19 -19
View File
@@ -5,31 +5,31 @@ description = "Point of Sale for a restaurant"
requires-python = ">=3.14" requires-python = ">=3.14"
authors = [{ name = "tanshu", email = "git@tanshu.com" }] authors = [{ name = "tanshu", email = "git@tanshu.com" }]
dependencies = [ dependencies = [
"aiohttp>=3.13.3", "aiohttp>=3.14.3",
"alembic>=1.18.4", "alembic>=1.19.1",
"arq>=0.27.0", "arq>=0.28.0",
"cryptography>=46.0.5", "cryptography>=50.0.0",
"fastapi[all]>=0.129.0", "fastapi[all]>=0.141.1",
"gunicorn>=25.1.0", "gunicorn>=26.0.0",
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
"passlib[bcrypt]>=1.7.4", "passlib[bcrypt]>=1.7.4",
"psycopg[binary,pool]>=3.3.3", "psycopg[binary,pool]>=3.3.4",
"pydantic>=2.12.5", "pydantic>=2.13.4",
"pydantic-settings>=2.13.1", "pydantic-settings>=2.15.0",
"pyjwt>=2.11.0", "pyjwt>=2.13.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.2",
"python-multipart>=0.0.22", "python-multipart>=0.0.32",
"sqlalchemy>=2.0.46", "sqlalchemy>=2.0.51",
"uvicorn[standard]>=0.41.0", "uvicorn[standard]>=0.52.1",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"bandit>=1.9.3", "bandit>=1.9.4",
"mypy>=1.19.1", "mypy>=2.3.0",
"pre-commit>=4.5.1", "pre-commit>=4.6.1",
"ruff>=0.15.2", "ruff>=0.16.2",
"safety>=3.7.0", "safety>=3.8.1",
] ]
[build-system] [build-system]
+407 -363
View File
File diff suppressed because it is too large. Load diff
+17 -17
View File
@@ -11,36 +11,36 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.1.1", "@angular/cdk": "^22.1.1",
"@angular/common": "^21.1.1", "@angular/common": "^22.1.1",
"@angular/compiler": "^21.1.1", "@angular/compiler": "^22.1.1",
"@angular/core": "^21.1.1", "@angular/core": "^22.1.1",
"@angular/forms": "^21.1.1", "@angular/forms": "^22.1.1",
"@angular/material": "^21.1.1", "@angular/material": "^22.1.1",
"@angular/material-moment-adapter": "^21.1.1", "@angular/material-moment-adapter": "^22.1.1",
"@angular/platform-browser": "^21.1.1", "@angular/platform-browser": "^22.1.1",
"@angular/platform-browser-dynamic": "^21.1.1", "@angular/router": "^22.1.1",
"@angular/router": "^21.1.1",
"mathjs": "^15.1.0", "mathjs": "^15.1.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.6.0" "tslib": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.1.1", "@angular/build": "^22.1.3",
"@angular/cli": "^21.1.1", "@angular/cli": "^22.1.3",
"@angular/compiler-cli": "^21.1.1", "@angular/compiler-cli": "^22.1.1",
"angular-eslint": "^21.1.0", "@eslint/js": "^10.0.1",
"eslint": "^9.28.0", "angular-eslint": "^22.1.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-perfectionist": "^5.4.0", "eslint-plugin-perfectionist": "^5.4.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.2", "lint-staged": "^17.3.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"standard-version": "^9.5.0", "standard-version": "^9.5.0",
"typescript": "~5.9.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.33.1", "typescript-eslint": "^8.33.1",
"vitest": "^4.0.8" "vitest": "^4.0.8"
}, },
+2 -1
View File
@@ -18,7 +18,7 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { provideRouter, withRouterConfig } from '@angular/router'; import { provideRouter, withComponentInputBinding, withRouterConfig } from '@angular/router';
import { dateFormat } from './app.environment'; import { dateFormat } from './app.environment';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -55,6 +55,7 @@ export const appConfig: ApplicationConfig = {
withRouterConfig({ withRouterConfig({
onSameUrlNavigation: 'reload', onSameUrlNavigation: 'reload',
}), }),
withComponentInputBinding(),
), ),
{ provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] }, { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] },
{ provide: MAT_DATE_FORMATS, useValue: dateFormat }, { provide: MAT_DATE_FORMATS, useValue: dateFormat },
+3
View File
@@ -7,9 +7,12 @@ export const environment = {
export const dateFormat = { export const dateFormat = {
parse: { parse: {
dateInput: 'DD-MMM-YYYY', dateInput: 'DD-MMM-YYYY',
timeInput: 'hh:mm A',
}, },
display: { display: {
dateInput: 'DD-MMM-YYYY', dateInput: 'DD-MMM-YYYY',
timeInput: 'hh:mm A',
timeOptionLabel: 'hh:mm A',
monthYearLabel: 'MMM YYYY', monthYearLabel: 'MMM YYYY',
dateA11yLabel: 'DD-MMM-YYYY', dateA11yLabel: 'DD-MMM-YYYY',
monthYearA11yLabel: 'MMM YYYY', monthYearA11yLabel: 'MMM YYYY',
@@ -1,23 +1,23 @@
<h2>Login</h2> <h2>Login</h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Username</mat-label> <mat-label>Username</mat-label>
<input matInput #nameElement formControlName="username" /> <input matInput #nameElement [formField]="form.username" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input matInput formControlName="password" [type]="hide ? 'password' : 'text'" (keyup.enter)="login()" /> <input matInput [formField]="form.password" [type]="hide ? 'password' : 'text'" (keyup.enter)="login()" />
<mat-icon matSuffix (click)="hide = !hide">{{ hide ? 'visibility' : 'visibility_off' }}</mat-icon> <mat-icon matSuffix (click)="hide = !hide">{{ hide ? 'visibility' : 'visibility_off' }}</mat-icon>
</mat-form-field> </mat-form-field>
</div> </div>
@if (unregisteredDevice) { @if (unregisteredDevice()) {
<mat-divider></mat-divider> <mat-divider></mat-divider>
<h2>Sorry, device {{ deviceName }} is not enabled.</h2> <h2>Sorry, device {{ deviceName() }} is not enabled.</h2>
} }
</form> </form>
<div class="row-container"> <div class="row-container">
<button mat-raised-button (click)="login()" color="primary">Login</button> <button type="button" mat-raised-button (click)="login()" color="primary">Login</button>
</div> </div>
+34 -21
View File
@@ -1,5 +1,14 @@
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import {
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
OnInit,
ViewChild,
inject,
signal,
} from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -11,13 +20,19 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CookieService } from '../../shared/cookie.service'; import { CookieService } from '../../shared/cookie.service';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
export interface LoginFormData {
username: string;
password: string;
}
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrls: ['./login.component.css'], styleUrls: ['./login.component.css'],
imports: [MatButtonModule, MatDividerModule, MatFormFieldModule, MatIconModule, MatInputModule, ReactiveFormsModule], imports: [MatButtonModule, MatDividerModule, MatFormFieldModule, MatIconModule, MatInputModule, FormField],
}) })
export class LoginComponent implements OnInit, AfterViewInit { export class LoginComponent implements OnInit, AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private auth = inject(AuthService); private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
@@ -25,28 +40,26 @@ export class LoginComponent implements OnInit, AfterViewInit {
private cs = inject(CookieService); private cs = inject(CookieService);
private cd = inject(ChangeDetectorRef); private cd = inject(ChangeDetectorRef);
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef; formModel = signal<LoginFormData>({
form: FormGroup<{ username: '',
username: FormControl<string>; password: '',
password: FormControl<string>; });
}>;
form = form(this.formModel, (schemaPath) => {
required(schemaPath.username);
required(schemaPath.password);
});
hide: boolean; hide: boolean;
unregisteredDevice: boolean; unregisteredDevice = signal<boolean | undefined>(undefined);
deviceName: string; deviceName = signal<string | undefined>(undefined);
private returnUrl: string; private returnUrl: string;
constructor() { constructor() {
this.hide = true; this.hide = true;
this.unregisteredDevice = false; this.unregisteredDevice.set(false);
this.deviceName = ''; this.deviceName.set('');
this.returnUrl = ''; this.returnUrl = '';
// Create form
this.form = new FormGroup({
username: new FormControl('', { validators: Validators.required, nonNullable: true }),
password: new FormControl('', { validators: Validators.required, nonNullable: true }),
});
} }
ngOnInit() { ngOnInit() {
@@ -62,7 +75,7 @@ export class LoginComponent implements OnInit, AfterViewInit {
} }
login(): void { login(): void {
const formModel = this.form.value; const formModel = this.formModel();
const username = formModel.username ?? ''; const username = formModel.username ?? '';
const password = formModel.password ?? ''; const password = formModel.password ?? '';
this.auth.login(username.trim(), password.trim()).subscribe({ this.auth.login(username.trim(), password.trim()).subscribe({
@@ -71,8 +84,8 @@ export class LoginComponent implements OnInit, AfterViewInit {
}, },
error: (error) => { error: (error) => {
if (error.status === 401 && error.error.detail === 'Device is not registered') { if (error.status === 401 && error.error.detail === 'Device is not registered') {
this.unregisteredDevice = true; this.unregisteredDevice.set(true);
this.deviceName = this.cs.getCookie('device'); this.deviceName.set(this.cs.getCookie('device'));
this.cd.detectChanges(); this.cd.detectChanges();
} }
this.snackBar.open(error.error.detail, 'Error'); this.snackBar.open(error.error.detail, 'Error');
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable, of as observableOf } from 'rxjs';
import { BeerSaleReportItem } from './beer-sale-report-item';
export class BeerSaleReportDataSource extends DataSource<BeerSaleReportItem> {
constructor(public data: BeerSaleReportItem[]) {
super();
}
connect(): Observable<BeerSaleReportItem[]> {
return observableOf(this.data);
}
disconnect() {}
}
@@ -1,11 +1,14 @@
<h2 class="row-container space-between"> <h2 class="row-container space-between">
<span>Beer Sale Report</span> <span>Beer Sale Report</span>
<button mat-icon-button (click)="exportCsv()"> <button type="button" mat-icon-button (click)="exportCsv()">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> @if (!infoResource.value() || !saleCategoriesResource.value()) {
<p>Loading...</p>
} @else {
<form class="flex-col">
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-2-5"> <mat-form-field class="flex-auto basis-2-5">
<mat-label>Start Date</mat-label> <mat-label>Start Date</mat-label>
@@ -13,7 +16,7 @@
matInput matInput
[matDatepicker]="startDate" [matDatepicker]="startDate"
(focus)="startDate.open()" (focus)="startDate.open()"
formControlName="startDate" [formField]="form.startDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
@@ -25,7 +28,7 @@
matInput matInput
[matDatepicker]="finishDate" [matDatepicker]="finishDate"
(focus)="finishDate.open()" (focus)="finishDate.open()"
formControlName="finishDate" [formField]="form.finishDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
@@ -33,24 +36,36 @@
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Sale Category</mat-label> <mat-label>Sale Category</mat-label>
<mat-select formControlName="saleCategory" (selectionChange)="filterOn($event.value)"> <mat-select [formField]="form.saleCategory" (selectionChange)="filterOn($event.value)">
@for (sc of saleCategories; track sc) { @for (sc of saleCategories(); track sc) {
<mat-option [value]="sc.id"> <mat-option [value]="sc.id">
{{ sc.name }} {{ sc.name }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
@for (error of form.saleCategory().errors(); track error) {
<mat-error>{{ error.message }}</mat-error>
}
</mat-form-field> </mat-form-field>
<button mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button> <button
type="button"
mat-raised-button
class="flex-auto basis-1-5"
color="primary"
[disabled]="form().invalid()"
(click)="show()"
>
Show
</button>
</div> </div>
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-checkbox class="flex-auto" formControlName="regular">Regular</mat-checkbox> <mat-checkbox class="flex-auto" [formField]="form.regular">Regular</mat-checkbox>
<mat-checkbox class="flex-auto" formControlName="happy">Happy Hour</mat-checkbox> <mat-checkbox class="flex-auto" [formField]="form.happy">Happy Hour</mat-checkbox>
<mat-checkbox class="flex-auto" formControlName="staff">Staff Consumption</mat-checkbox> <mat-checkbox class="flex-auto" [formField]="form.staff">Staff Consumption</mat-checkbox>
<mat-checkbox class="flex-auto" formControlName="nc">No Charge</mat-checkbox> <mat-checkbox class="flex-auto" [formField]="form.nc">No Charge</mat-checkbox>
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <mat-table #table [dataSource]="dataSource()" aria-label="Elements">
<!-- Date Column --> <!-- Date Column -->
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>Time</mat-header-cell> <mat-header-cell *matHeaderCellDef>Time</mat-header-cell>
@@ -58,13 +73,14 @@
</ng-container> </ng-container>
<!-- Name Column --> <!-- Name Column -->
@for (col of info.headers; track col) { @for (col of info()?.headers ?? []; track col) {
<ng-container matColumnDef="{{ col }}"> <ng-container matColumnDef="{{ col }}">
<mat-header-cell *matHeaderCellDef class="right">{{ col }}</mat-header-cell> <mat-header-cell *matHeaderCellDef class="right">{{ col }}</mat-header-cell>
<mat-cell *matCellDef="let row" class="right">{{ row[col] | number: '1.2-2' }}</mat-cell> <mat-cell *matCellDef="let row" class="right">{{ row[col] | number: '1.2-2' }}</mat-cell>
</ng-container> </ng-container>
} }
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-header-row *matHeaderRowDef="displayedColumns()"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> <mat-row *matRowDef="let row; columns: displayedColumns()"></mat-row>
</mat-table> </mat-table>
}
@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
@@ -13,10 +13,21 @@ import { ActivatedRoute, Router } from '@angular/router';
import moment from 'moment'; import moment from 'moment';
import { SaleCategory } from '../core/sale-category'; import { SaleCategory } from '../core/sale-category';
import { SaleCategoryService } from '../sale-category/sale-category.service';
import { ToCsvService } from '../shared/to-csv.service'; import { ToCsvService } from '../shared/to-csv.service';
import { BeerSaleExportHeaderInterface } from './beer-sale-export-header-interface'; import { BeerSaleExportHeaderInterface } from './beer-sale-export-header-interface';
import { BeerSaleReport } from './beer-sale-report'; import { BeerSaleReport } from './beer-sale-report';
import { BeerSaleReportDataSource } from './beer-sale-report-datasource'; import { BeerSaleReportService } from './beer-sale-report.service';
export interface BeerSaleReportFormData {
startDate: Date;
finishDate: Date;
saleCategory: string;
regular: boolean;
happy: boolean;
staff: boolean;
nc: boolean;
}
@Component({ @Component({
selector: 'app-beer-sale-report', selector: 'app-beer-sale-report',
@@ -32,61 +43,72 @@ import { BeerSaleReportDataSource } from './beer-sale-report-datasource';
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class BeerSaleReportComponent implements OnInit { export class BeerSaleReportComponent {
private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute);
private toCsv = inject(ToCsvService); private toCsv = inject(ToCsvService);
private ser = inject(BeerSaleReportService);
private saleCategoryService = inject(SaleCategoryService);
startDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
finishDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
saleCategory = input(null, { transform: (v: string | null | undefined) => v ?? null });
regular = input(true, { transform: (v: string | null | undefined) => v !== 'false' });
happy = input(true, { transform: (v: string | null | undefined) => v !== 'false' });
staff = input(true, { transform: (v: string | null | undefined) => v !== 'false' });
nc = input(true, { transform: (v: string | null | undefined) => v !== 'false' });
saleCategories: SaleCategory[] = []; saleCategoriesResource = this.saleCategoryService.list();
info: BeerSaleReport = new BeerSaleReport(); infoResource = this.ser.get(
dataSource: BeerSaleReportDataSource = new BeerSaleReportDataSource(this.info.data); this.startDate,
form: FormGroup<{ this.finishDate,
startDate: FormControl<Date>; this.saleCategory,
finishDate: FormControl<Date>; this.regular,
saleCategory: FormControl<string>; this.happy,
regular: FormControl<boolean>; this.staff,
happy: FormControl<boolean>; this.nc,
staff: FormControl<boolean>; );
nc: FormControl<boolean>;
}>; saleCategories = computed(() => this.saleCategoriesResource.value() ?? []);
info = computed(() => this.infoResource.value());
dataSource = computed(() => this.info()?.data ?? []);
formModel = linkedSignal({
source: () => this.info(),
computation: (info): BeerSaleReportFormData => {
if (info) {
return {
startDate: moment(info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(info.finishDate, 'DD-MMM-YYYY').toDate(),
saleCategory: info.saleCategory?.id ?? '',
regular: info.regular,
happy: info.happy,
staff: info.staff,
nc: info.nc,
};
}
return {
startDate: new Date(),
finishDate: new Date(),
saleCategory: '',
regular: true,
happy: true,
staff: true,
nc: true,
};
},
});
form = form(this.formModel, (schemaPath) => {
required(schemaPath.saleCategory, { message: 'Sale Category is required' });
});
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns: string[] = ['date']; displayedColumns = computed(() => {
const headers = this.info()?.headers ?? [];
constructor() { return ['date'].concat(headers);
// Create form
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
saleCategory: new FormControl('', { nonNullable: true }),
regular: new FormControl<boolean>(true, { nonNullable: true }),
happy: new FormControl<boolean>(true, { nonNullable: true }),
staff: new FormControl<boolean>(true, { nonNullable: true }),
nc: new FormControl<boolean>(true, { nonNullable: true }),
}); });
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { info: BeerSaleReport; saleCategories: SaleCategory[] };
this.info = data.info;
this.saleCategories = data.saleCategories;
this.displayedColumns = ['date'].concat(this.info.headers);
this.form.setValue({
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
saleCategory: this.info.saleCategory?.id ?? '',
regular: this.info.regular,
happy: this.info.happy,
staff: this.info.staff,
nc: this.info.nc,
});
this.dataSource = new BeerSaleReportDataSource(this.info.data);
});
}
show() { show() {
const info = this.getInfo(); const info = this.getInfo();
@@ -104,17 +126,21 @@ export class BeerSaleReportComponent implements OnInit {
} }
filterOn(id: string) { filterOn(id: string) {
const sc = this.saleCategories.find((x) => x.id === id); const sc = this.saleCategories().find((x: SaleCategory) => x.id === id);
this.info.saleCategory = sc ? sc : new SaleCategory({ id }); const currentInfo = this.info();
if (currentInfo) {
currentInfo.saleCategory = sc ? sc : new SaleCategory({ id });
}
} }
getInfo(): BeerSaleReport { getInfo(): BeerSaleReport {
const formModel = this.form.value; const formModel = this.formModel();
const currentInfo = this.info();
return new BeerSaleReport({ return new BeerSaleReport({
startDate: moment(formModel.startDate).format('DD-MMM-YYYY'), startDate: moment(formModel.startDate).format('DD-MMM-YYYY'),
finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'), finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'),
saleCategory: this.info.saleCategory, saleCategory: currentInfo?.saleCategory,
regular: formModel.regular, regular: formModel.regular,
happy: formModel.happy, happy: formModel.happy,
staff: formModel.staff, staff: formModel.staff,
@@ -123,12 +149,18 @@ export class BeerSaleReportComponent implements OnInit {
} }
exportCsv() { exportCsv() {
const currentInfo = this.info();
if (!currentInfo) return;
const init: BeerSaleExportHeaderInterface = { Date: 'date' }; const init: BeerSaleExportHeaderInterface = { Date: 'date' };
const headers = this.info.headers.reduce((a, c) => { const headers = currentInfo.headers.reduce(
(a: Record<string, string>, c: string) => {
a[c] = c; a[c] = c;
return a; return a;
}, init); },
const csvData = new Blob([this.toCsv.toCsv(headers, this.info.data)], { init as Record<string, string>,
);
const csvData = new Blob([this.toCsv.toCsv(headers, currentInfo.data)], {
type: 'text/csv;charset=utf-8;', type: 'text/csv;charset=utf-8;',
}); });
const link = document.createElement('a'); const link = document.createElement('a');
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { BeerSaleReportResolver } from './beer-sale-report-resolver.service';
describe('BeerSaleReportResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BeerSaleReportResolver],
});
});
it('should be created', inject([BeerSaleReportResolver], (service: BeerSaleReportResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,16 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { BeerSaleReport } from './beer-sale-report';
import { BeerSaleReportService } from './beer-sale-report.service';
export const beerSaleReportResolver: ResolveFn<BeerSaleReport> = (route) => {
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
const saleCategory = route.queryParamMap.get('saleCategory') ?? null;
const regular = route.queryParamMap.get('regular') !== 'false';
const happy = route.queryParamMap.get('happy') !== 'false';
const staff = route.queryParamMap.get('staff') !== 'false';
const nc = route.queryParamMap.get('nc') !== 'false';
return inject(BeerSaleReportService).get(startDate, finishDate, saleCategory, regular, happy, staff, nc);
};
@@ -1,9 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { saleCategoryListResolver } from '../sale-category/sale-category-list.resolver';
import { BeerSaleReportComponent } from './beer-sale-report.component'; import { BeerSaleReportComponent } from './beer-sale-report.component';
import { beerSaleReportResolver } from './beer-sale-report.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -13,10 +11,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Beer Sale Report', permission: 'Beer Sale Report',
}, },
resolve: {
info: beerSaleReportResolver,
saleCategories: saleCategoryListResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
]; ];
@@ -1,46 +1,42 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { BeerSaleReport } from './beer-sale-report'; import { BeerSaleReport } from './beer-sale-report';
const url = '/api/beer-sale-report'; const url = '/api/beer-sale-report';
const serviceName = 'BeerSaleReportService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class BeerSaleReportService { export class BeerSaleReportService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
get( get(
startDate: string | null, startDate: Signal<string | null>,
finishDate: string | null, finishDate: Signal<string | null>,
saleCategory: string | null, saleCategory: Signal<string | null>,
regular: boolean, regular: Signal<boolean>,
happy: boolean, happy: Signal<boolean>,
staff: boolean, staff: Signal<boolean>,
nc: boolean, nc: Signal<boolean>,
): Observable<BeerSaleReport> { ): HttpResourceRef<BeerSaleReport | undefined> {
const options = { params: new HttpParams() }; return httpResource<BeerSaleReport>(() => {
if (startDate !== null) { let params = new HttpParams();
options.params = options.params.set('s', startDate); const s = startDate();
const f = finishDate();
const sc = saleCategory();
if (s !== null) {
params = params.set('s', s);
} }
if (finishDate !== null) { if (f !== null) {
options.params = options.params.set('f', finishDate); params = params.set('f', f);
} }
if (saleCategory != null) { if (sc !== null) {
options.params = options.params.set('sc', saleCategory); params = params.set('sc', sc);
} }
options.params = options.params.set('r', regular); params = params.set('r', regular());
options.params = options.params.set('h', happy); params = params.set('h', happy());
options.params = options.params.set('st', staff); params = params.set('st', staff());
options.params = options.params.set('n', nc); params = params.set('n', nc());
return this.http return { url, params };
.get<BeerSaleReport>(url, options) });
.pipe(catchError(this.log.handleError(serviceName, 'get'))) as Observable<BeerSaleReport>;
} }
} }
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable, of as observableOf } from 'rxjs';
import { BillSettlementReportItem } from './bill-settlement-report-item';
export class BillSettlementReportDataSource extends DataSource<BillSettlementReportItem> {
constructor(public data: BillSettlementReportItem[]) {
super();
}
connect(): Observable<BillSettlementReportItem[]> {
return observableOf(this.data);
}
disconnect() {}
}
@@ -1,11 +1,11 @@
<h2 class="row-container space-between"> <h2 class="row-container space-between">
<span>Bill Settlement Report</span> <span>Bill Settlement Report</span>
<button mat-icon-button (click)="exportCsv()"> <button type="button" mat-icon-button (click)="exportCsv()">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-2-5"> <mat-form-field class="flex-auto basis-2-5">
<mat-label>Start Date</mat-label> <mat-label>Start Date</mat-label>
@@ -13,7 +13,7 @@
matInput matInput
[matDatepicker]="startDate" [matDatepicker]="startDate"
(focus)="startDate.open()" (focus)="startDate.open()"
formControlName="startDate" [formField]="form.startDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
@@ -25,16 +25,16 @@
matInput matInput
[matDatepicker]="finishDate" [matDatepicker]="finishDate"
(focus)="finishDate.open()" (focus)="finishDate.open()"
formControlName="finishDate" [formField]="form.finishDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
<mat-datepicker #finishDate></mat-datepicker> <mat-datepicker #finishDate></mat-datepicker>
</mat-form-field> </mat-form-field>
<button mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button> <button type="button" mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button>
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <mat-table #table [dataSource]="dataSource()" aria-label="Elements">
<!-- Date Column --> <!-- Date Column -->
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>Time</mat-header-cell> <mat-header-cell *matHeaderCellDef>Time</mat-header-cell>
@@ -1,6 +1,6 @@
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -13,7 +13,12 @@ import moment from 'moment';
import { LocalTimePipe } from '../shared/local-time.pipe'; import { LocalTimePipe } from '../shared/local-time.pipe';
import { ToCsvService } from '../shared/to-csv.service'; import { ToCsvService } from '../shared/to-csv.service';
import { BillSettlementReport } from './bill-settlement-report'; import { BillSettlementReport } from './bill-settlement-report';
import { BillSettlementReportDataSource } from './bill-settlement-report-datasource'; import { BillSettlementReportService } from './bill-settlement-report.service';
export interface BillSettlementReportFormData {
startDate: Date;
finishDate: Date;
}
@Component({ @Component({
selector: 'app-bill-settlement-report', selector: 'app-bill-settlement-report',
@@ -23,51 +28,47 @@ import { BillSettlementReportDataSource } from './bill-settlement-report-datasou
DecimalPipe, DecimalPipe,
LocalTimePipe, LocalTimePipe,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatInputModule, MatInputModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class BillSettlementReportComponent implements OnInit { export class BillSettlementReportComponent {
private billSettlementReportService = inject(BillSettlementReportService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private toCsv = inject(ToCsvService); private toCsv = inject(ToCsvService);
startDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
finishDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
info: BillSettlementReport = new BillSettlementReport(); infoResource = this.billSettlementReportService.get(this.startDate, this.finishDate);
dataSource: BillSettlementReportDataSource = new BillSettlementReportDataSource(this.info.amounts);
form: FormGroup<{ info = computed(() => this.infoResource.value() ?? new BillSettlementReport());
startDate: FormControl<Date>; dataSource = computed(() => this.info().amounts);
finishDate: FormControl<Date>; formModel = linkedSignal({
}>; source: this.info,
computation: (infoVal): BillSettlementReportFormData => {
if (infoVal) {
return {
startDate: moment(this.info().startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info().finishDate, 'DD-MMM-YYYY').toDate(),
};
}
return {
startDate: new Date(),
finishDate: new Date(),
};
},
});
form = form(this.formModel);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['date', 'billId', 'amount', 'settlement']; displayedColumns = ['date', 'billId', 'amount', 'settlement'];
constructor() {
// Create form
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { info: BillSettlementReport };
this.info = data.info;
this.form.setValue({
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
});
this.dataSource = new BillSettlementReportDataSource(this.info.amounts);
});
}
show() { show() {
const info = this.getInfo(); const info = this.getInfo();
this.router.navigate(['bill-settlement-report'], { this.router.navigate(['bill-settlement-report'], {
@@ -79,7 +80,7 @@ export class BillSettlementReportComponent implements OnInit {
} }
getInfo(): BillSettlementReport { getInfo(): BillSettlementReport {
const formModel = this.form.value; const formModel = this.formModel();
return new BillSettlementReport({ return new BillSettlementReport({
startDate: moment(formModel.startDate).format('DD-MMM-YYYY'), startDate: moment(formModel.startDate).format('DD-MMM-YYYY'),
@@ -94,7 +95,7 @@ export class BillSettlementReportComponent implements OnInit {
Amount: 'amount', Amount: 'amount',
Settlement: 'settlement', Settlement: 'settlement',
}; };
const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource.data)], { const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource())], {
type: 'text/csv;charset=utf-8;', type: 'text/csv;charset=utf-8;',
}); });
const link = document.createElement('a'); const link = document.createElement('a');
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { BillSettlementReportResolver } from './bill-settlement-report-resolver.service';
describe('BillSettlementReportResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [BillSettlementReportResolver],
});
});
it('should be created', inject([BillSettlementReportResolver], (service: BillSettlementReportResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,11 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { BillSettlementReport } from './bill-settlement-report';
import { BillSettlementReportService } from './bill-settlement-report.service';
export const billSettlementReportResolver: ResolveFn<BillSettlementReport> = (route) => {
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
return inject(BillSettlementReportService).get(startDate, finishDate);
};
@@ -2,7 +2,6 @@ import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { BillSettlementReportComponent } from './bill-settlement-report.component'; import { BillSettlementReportComponent } from './bill-settlement-report.component';
import { billSettlementReportResolver } from './bill-settlement-report.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -12,9 +11,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Bill Settlement Report', permission: 'Bill Settlement Report',
}, },
resolve: {
info: billSettlementReportResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
]; ];
@@ -1,31 +1,29 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { BillSettlementReport } from './bill-settlement-report'; import { BillSettlementReport } from './bill-settlement-report';
const url = '/api/bill-settlement-report'; const url = '/api/bill-settlement-report';
const serviceName = 'BillSettlementReportService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class BillSettlementReportService { export class BillSettlementReportService {
private http = inject(HttpClient); get(
private log = inject(ErrorLoggerService); startDate: Signal<string | null>,
finishDate: Signal<string | null>,
get(startDate: string | null, finishDate: string | null): Observable<BillSettlementReport> { ): HttpResourceRef<BillSettlementReport | undefined> {
const options = { params: new HttpParams() }; return httpResource<BillSettlementReport>(() => {
if (startDate !== null) { let params = new HttpParams();
options.params = options.params.set('s', startDate); const s = startDate();
const f = finishDate();
if (s !== null) {
params = params.set('s', s);
} }
if (finishDate !== null) { if (f !== null) {
options.params = options.params.set('f', finishDate); params = params.set('f', f);
} }
return this.http return { url, params };
.get<BillSettlementReport>(url, options) });
.pipe(catchError(this.log.handleError(serviceName, 'get'))) as Observable<BillSettlementReport>;
} }
} }
@@ -1,16 +0,0 @@
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() {}
}
@@ -1,12 +1,12 @@
<h1 mat-dialog-title>Edit Bundle Item</h1> <h1 mat-dialog-title>Edit Bundle Item</h1>
<div mat-dialog-content> <div mat-dialog-content>
<form [formGroup]="form"> <form>
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Item</mat-label> <mat-label>Item</mat-label>
<input matInput type="text" formControlName="product" [matAutocomplete]="autoP" /> <input matInput type="text" [formField]="form.product" [matAutocomplete]="autoP" />
<mat-autocomplete #autoP="matAutocomplete" [displayWith]="displayFn" (optionSelected)="productSelected($event)"> <mat-autocomplete #autoP="matAutocomplete" [displayWith]="displayFn" (optionSelected)="productSelected($event)">
@for (p of products$ | async; track p) { @for (p of products(); track p) {
<mat-option [value]="p">{{ p.name }}</mat-option> <mat-option [value]="p">{{ p.name }}</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>
@@ -15,19 +15,19 @@
<div class="flex flex-row flex-wrap gap-3"> <div class="flex flex-row flex-wrap gap-3">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label> <mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" /> <input matInput type="number" [formField]="form.quantity" />
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label> <mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" /> <input matInput type="number" [formField]="form.salePrice" />
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="printInBill" class="flex-auto">Print in Bill?</mat-checkbox> <mat-checkbox [formField]="form.printInBill" class="flex-auto">Print in Bill?</mat-checkbox>
</div> </div>
</form> </form>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>
<button mat-raised-button color="warn" [mat-dialog-close]="false">Cancel</button> <button mat-raised-button color="warn" [mat-dialog-close]="false">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button> <button type="button" mat-raised-button color="primary" (click)="accept()">Ok</button>
</div> </div>
@@ -1,18 +1,24 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject, signal, computed, effect, debounced } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { debounceTime, distinctUntilChanged, Observable, of as observableOf, switchMap } from 'rxjs';
import { ProductQuery } from '../../core/product-query'; import { ProductQuery } from '../../core/product-query';
import { ProductService } from '../../product/product.service'; import { ProductService } from '../../product/product.service';
import { BundleItem } from '../bundle'; import { BundleItem } from '../bundle';
export interface BundleDetailDialogFormData {
product: ProductQuery | string;
quantity: number;
salePrice: number;
printInBill: boolean;
}
@Component({ @Component({
selector: 'app-bundle-detail-dialog', selector: 'app-bundle-detail-dialog',
templateUrl: './bundle-detail-dialog.component.html', templateUrl: './bundle-detail-dialog.component.html',
@@ -26,7 +32,7 @@ import { BundleItem } from '../bundle';
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class BundleDetailDialogComponent implements OnInit { export class BundleDetailDialogComponent implements OnInit {
@@ -35,49 +41,48 @@ export class BundleDetailDialogComponent implements OnInit {
data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA); data = inject<{ item: BundleItem }>(MAT_DIALOG_DATA);
selectedProduct: ProductQuery | null = null; selectedProduct = signal<ProductQuery | null>(null);
products$: Observable<ProductQuery[]>;
form: FormGroup<{ formModel = signal<BundleDetailDialogFormData>({
product: FormControl<ProductQuery | string>; product: '',
quantity: FormControl<number>; quantity: 1,
salePrice: FormControl<number>; salePrice: 0,
printInBill: FormControl<boolean>; printInBill: true,
}>;
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 }),
printInBill: new FormControl<boolean>(true, { nonNullable: true }),
}); });
this.products$ = this.form.controls.product.valueChanges.pipe( form = form(this.formModel);
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
if (typeof x !== 'string') return observableOf([]);
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
productSignal = computed(() => {
const p = this.formModel().product;
return typeof p === 'string' ? p.trim() : '';
});
debouncedProduct = debounced(this.productSignal, 150);
productsResource = this.productSer.query(computed(() => this.debouncedProduct.value()));
products = computed(() => {
if (!this.debouncedProduct.value()) return [];
return this.productsResource.value() ?? [];
});
constructor() {
// typing after selecting invalidates prior selection // typing after selecting invalidates prior selection
this.form.controls.product.valueChanges.subscribe((x) => { effect(() => {
if (typeof x === 'string') this.selectedProduct = null; const x = this.formModel().product;
if (typeof x === 'string') this.selectedProduct.set(null);
}); });
} }
ngOnInit(): void { ngOnInit(): void {
// preload display text using existing name // preload display text using existing name
this.form.setValue({ this.formModel.set({
product: this.data.item.name ?? '', product: this.data.item.name ?? '',
quantity: Number(this.data.item.quantity ?? 1), quantity: Number(this.data.item.quantity ?? 1),
salePrice: Number(this.data.item.salePrice ?? 0), salePrice: Number(this.data.item.salePrice ?? 0),
printInBill: this.data.item.printInBill, printInBill: this.data.item.printInBill,
}); });
this.selectedProduct = null; this.selectedProduct.set(null);
} }
displayFn(product?: ProductQuery | string): string { displayFn(product?: ProductQuery | string): string {
@@ -86,11 +91,11 @@ export class BundleDetailDialogComponent implements OnInit {
productSelected(event: MatAutocompleteSelectedEvent): void { productSelected(event: MatAutocompleteSelectedEvent): void {
const p = event.option.value as ProductQuery; const p = event.option.value as ProductQuery;
this.selectedProduct = p; this.selectedProduct.set(p);
} }
accept(): void { accept(): void {
const v = this.form.value; const v = this.formModel();
const quantity = Number(v.quantity ?? 0); const quantity = Number(v.quantity ?? 0);
if (Number.isNaN(quantity) || quantity <= 0) return; if (Number.isNaN(quantity) || quantity <= 0) return;
@@ -99,9 +104,9 @@ export class BundleDetailDialogComponent implements OnInit {
if (Number.isNaN(salePrice) || salePrice < 0) return; if (Number.isNaN(salePrice) || salePrice < 0) return;
// If a new product was selected, update itemId + name // If a new product was selected, update itemId + name
if (this.selectedProduct?.id) { if (this.selectedProduct()?.id) {
this.data.item.itemId = this.selectedProduct.id; this.data.item.itemId = this.selectedProduct()?.id ?? '';
this.data.item.name = this.selectedProduct.name ?? ''; this.data.item.name = this.selectedProduct()?.name ?? '';
} else { } else {
// If no selection, don't allow changing SKU silently // If no selection, don't allow changing SKU silently
// but still allow quantity/price edit // but still allow quantity/price edit
@@ -1,29 +1,29 @@
<h2>Bundle</h2> <h2>Bundle</h2>
<form [formGroup]="form"> <form>
<div class="row-container"> <div class="row-container">
<mat-form-field class="basis-3-4"> <mat-form-field class="basis-3-4">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input matInput #name formControlName="name" /> <input matInput #name [formField]="form.name" />
</mat-form-field> </mat-form-field>
<mat-form-field class="basis-1-4"> <mat-form-field class="basis-1-4">
<mat-label>Units</mat-label> <mat-label>Units</mat-label>
<input matInput formControlName="units" /> <input matInput [formField]="form.units" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label> <mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" readonly="" /> <input matInput type="number" [value]="formModel().salePrice" readonly />
<mat-hint>Computed from bundle items</mat-hint> <mat-hint>Computed from bundle items</mat-hint>
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Menu Category</mat-label> <mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory"> <mat-select [formField]="form.menuCategory">
@for (mc of menuCategories; track mc) { @for (mc of menuCategories(); track mc) {
<mat-option [value]="mc.id"> <mat-option [value]="mc.id">
{{ mc.name }} {{ mc.name }}
</mat-option> </mat-option>
@@ -33,8 +33,8 @@
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Sale Category</mat-label> <mat-label>Sale Category</mat-label>
<mat-select formControlName="saleCategory"> <mat-select [formField]="form.saleCategory">
@for (sc of saleCategories; track sc) { @for (sc of saleCategories(); track sc) {
<mat-option [value]="sc.id"> <mat-option [value]="sc.id">
{{ sc.name }} {{ sc.name }}
</mat-option> </mat-option>
@@ -49,24 +49,24 @@
> >
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-checkbox formControlName="hasHappyHour" class="flex-auto">Has Happy Hour?</mat-checkbox> <mat-checkbox [formField]="form.hasHappyHour" class="flex-auto">Has Happy Hour?</mat-checkbox>
<mat-checkbox formControlName="isNotAvailable" class="flex-auto">Not Available?</mat-checkbox> <mat-checkbox [formField]="form.isNotAvailable" class="flex-auto">Not Available?</mat-checkbox>
</div> </div>
<h2>Items</h2> <h2>Items</h2>
<div formGroupName="addRow" class="nutrition-grid"> <div class="nutrition-grid">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Product</mat-label> <mat-label>Product</mat-label>
<input <input
type="text" type="text"
matInput matInput
formControlName="itemId" [formField]="form.addRow.itemId"
[matAutocomplete]="autoItem" [matAutocomplete]="autoItem"
[value]="displayFn(form.controls.addRow.controls.itemId.value)" [value]="displayFn(formModel().addRow.itemId)"
/> />
<mat-autocomplete #autoItem="matAutocomplete" [displayWith]="displayFn" (optionSelected)="bundleSelected($event)"> <mat-autocomplete #autoItem="matAutocomplete" [displayWith]="displayFn" (optionSelected)="bundleSelected($event)">
@for (p of itemProducts | async; track p) { @for (p of itemProducts(); track p) {
<mat-option [value]="p">{{ p.name }}</mat-option> <mat-option [value]="p">{{ p.name }}</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>
@@ -74,21 +74,21 @@
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Quantity</mat-label> <mat-label>Quantity</mat-label>
<input matInput type="number" formControlName="quantity" /> <input matInput type="number" [formField]="form.addRow.quantity" />
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Sale Price</mat-label> <mat-label>Sale Price</mat-label>
<input matInput type="number" formControlName="salePrice" /> <input matInput type="number" [formField]="form.addRow.salePrice" />
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="printInBill" class="basis-1-4">Print in Bill?</mat-checkbox> <mat-checkbox [formField]="form.addRow.printInBill" class="basis-1-4">Print in Bill?</mat-checkbox>
<button mat-raised-button color="primary" (click)="addRow()" class="flex-auto">Add</button> <button type="button" mat-raised-button color="primary" (click)="addRow()" class="flex-auto">Add</button>
</div> </div>
</form> </form>
<div class="row-container wrapped"> <div class="row-container wrapped">
<mat-table [dataSource]="dataSource" aria-label="Bundle Items" class="flex-auto"> <mat-table [dataSource]="dataSource()" aria-label="Bundle Items" class="flex-auto">
<!-- Name Column (name is "name (units)") --> <!-- Name Column (name is "name (units)") -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Item</mat-header-cell> <mat-header-cell *matHeaderCellDef>Item</mat-header-cell>
@@ -127,10 +127,10 @@
<ng-container matColumnDef="action"> <ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell> <mat-header-cell *matHeaderCellDef class="center">Action</mat-header-cell>
<mat-cell *matCellDef="let row" class="center"> <mat-cell *matCellDef="let row" class="center">
<button mat-icon-button tabindex="-1" (click)="editRow(row)"> <button type="button" mat-icon-button tabindex="-1" (click)="editRow(row)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
</button> </button>
<button mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)"> <button type="button" mat-icon-button tabindex="-1" color="warn" (click)="deleteRow(row)">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
</button> </button>
</mat-cell> </mat-cell>
@@ -142,8 +142,8 @@
</div> </div>
<div class="row-container"> <div class="row-container">
<button mat-raised-button color="primary" (click)="save()">Save</button> <button type="button" mat-raised-button color="primary" (click)="save()">Save</button>
@if (!!item.id) { @if (!!item.id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button> <button type="button" mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
} }
</div> </div>
@@ -1,6 +1,18 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import {
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; AfterViewInit,
Component,
ElementRef,
ViewChild,
inject,
computed,
signal,
effect,
debounced,
linkedSignal,
input,
} from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -13,24 +25,39 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { of as observableOf, BehaviorSubject, debounceTime, distinctUntilChanged, Observable, switchMap } from 'rxjs';
import { MenuCategory } from '../../core/menu-category'; import { MenuCategory } from '../../core/menu-category';
import { ProductQuery } from '../../core/product-query'; import { ProductQuery } from '../../core/product-query';
import { SaleCategory } from '../../core/sale-category'; import { SaleCategory } from '../../core/sale-category';
import { MenuCategoryService } from '../../menu-category/menu-category.service';
import { ProductService } from '../../product/product.service'; import { ProductService } from '../../product/product.service';
import { SaleCategoryService } from '../../sale-category/sale-category.service';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { Bundle, BundleItem } from '../bundle'; import { Bundle, BundleItem } from '../bundle';
import { BundleService } from '../bundle.service'; import { BundleService } from '../bundle.service';
import { BundleDetailDatasource } from './bundle-detail-datasource';
import { BundleDetailDialogComponent } from './bundle-detail-dialog.component'; import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
export interface BundleDetailFormData {
name: string;
units: string;
salePrice: number;
menuCategory: string;
saleCategory: string;
hasHappyHour: boolean;
isNotAvailable: boolean;
addRow: {
itemId: ProductQuery | string;
quantity: number;
salePrice: number;
printInBill: boolean;
};
}
@Component({ @Component({
selector: 'app-bundle-detail', selector: 'app-bundle-detail',
templateUrl: './bundle-detail.component.html', templateUrl: './bundle-detail.component.html',
styleUrls: ['./bundle-detail.component.css'], styleUrls: ['./bundle-detail.component.css'],
imports: [ imports: [
AsyncPipe,
CurrencyPipe, CurrencyPipe,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
@@ -41,127 +68,32 @@ import { BundleDetailDialogComponent } from './bundle-detail-dialog.component';
MatOptionModule, MatOptionModule,
MatSelectModule, MatSelectModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class BundleDetailComponent implements OnInit, AfterViewInit { export class BundleDetailComponent implements AfterViewInit {
@ViewChild('name', { static: true }) nameElement?: ElementRef;
private menuCategoryService = inject(MenuCategoryService);
private saleCategoryService = inject(SaleCategoryService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(BundleService); private ser = inject(BundleService);
private productSer = inject(ProductService); private productSer = inject(ProductService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
@ViewChild('name', { static: true }) nameElement?: ElementRef; saleCategoriesResource = this.saleCategoryService.list();
form: FormGroup<{ menuCategoriesResource = this.menuCategoryService.list(undefined);
// Bundle header fields
name: FormControl<string>;
units: FormControl<string>;
salePrice: FormControl<number>;
menuCategory: FormControl<string>;
saleCategory: FormControl<string>;
hasHappyHour: FormControl<boolean>;
isNotAvailable: FormControl<boolean>;
// Item add row itemResource = this.ser.get(this.id);
addRow: FormGroup<{
itemId: FormControl<ProductQuery | string>;
quantity: FormControl<number>;
salePrice: FormControl<number>;
printInBill: FormControl<boolean>;
}>;
}>;
menuCategories: MenuCategory[] = []; formModel = linkedSignal({
saleCategories: SaleCategory[] = []; source: () => this.menuCategoriesResource.value(),
public items$ = new BehaviorSubject<BundleItem[]>([]); computation: (menuCategoriesVal): BundleDetailFormData => {
dataSource: BundleDetailDatasource = new BundleDetailDatasource(this.items$); if (menuCategoriesVal) {
return {
item: Bundle = new Bundle();
itemProduct: ProductQuery | null = null;
itemProducts: Observable<ProductQuery[]>;
displayedColumns = ['name', 'quantity', 'salePrice', 'printInBill', '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 }),
saleCategory: 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 }),
printInBill: new FormControl<boolean>(true, { nonNullable: true }),
}),
});
this.itemProducts = this.form.controls.addRow.controls.itemId.valueChanges.pipe(
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => {
// if user types, x is string; if user selects, x is Product
if (typeof x !== 'string') {
return observableOf([]);
}
return x.trim() === '' ? observableOf([]) : this.productSer.query(x);
}),
);
this.form.controls.addRow.controls.itemId.valueChanges.subscribe((x) => {
if (typeof x === 'string') {
this.itemProduct = null;
}
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as {
item: Bundle;
menuCategories: MenuCategory[];
saleCategories: SaleCategory[];
};
this.menuCategories = data.menuCategories;
this.saleCategories = data.saleCategories;
this.showItem(data.item);
});
}
displayFn(product?: ProductQuery | string): string {
return !product ? '' : typeof product === 'string' ? product : product.name;
}
bundleSelected(event: MatAutocompleteSelectedEvent): void {
const product = event.option.value as ProductQuery;
this.itemProduct = product;
this.form.controls.addRow.controls.salePrice.setValue(product.price ?? 0);
}
private recalcBundleSalePrice(): void {
const total = (this.item.items ?? []).reduce((sum, x) => {
const qty = Number(x.quantity ?? 0);
const price = Number(x.salePrice ?? 0);
if (Number.isNaN(qty) || Number.isNaN(price)) return sum;
return sum + qty * price;
}, 0);
// keep model + form in sync
this.item.salePrice = Number(total.toFixed(2));
// if the control is disabled, use patchValue
this.form.controls.salePrice.patchValue(this.item.salePrice);
}
showItem(item: Bundle) {
this.item = item;
this.form.setValue({
name: this.item.name ?? '', name: this.item.name ?? '',
units: this.item.units ?? '', units: this.item.units ?? '',
salePrice: Number(this.item.salePrice ?? 0), salePrice: Number(this.item.salePrice ?? 0),
@@ -176,13 +108,105 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
salePrice: 0, salePrice: 0,
printInBill: true, printInBill: true,
}, },
};
}
return {
name: '',
units: '',
salePrice: 0,
menuCategory: '',
saleCategory: '',
hasHappyHour: false,
isNotAvailable: false,
addRow: {
itemId: '',
quantity: 1,
salePrice: 0,
printInBill: true,
},
};
},
}); });
this.itemProduct = null;
this.form.controls.salePrice.disable({ emitEvent: false });
this.items$.next(this.item.items ?? []); form = form(this.formModel);
menuCategories = signal<MenuCategory[]>([]);
saleCategories = signal<SaleCategory[]>([]);
items = signal<BundleItem[]>([]);
dataSource = computed(() => this.items());
item: Bundle = new Bundle();
itemProduct = signal<ProductQuery | null>(null);
itemIdSignal = computed(() => {
const x = this.formModel().addRow?.itemId;
return typeof x === 'string' ? x.trim() : '';
});
debouncedItemId = debounced(this.itemIdSignal, 150);
itemProductsResource = this.productSer.query(computed(() => this.debouncedItemId.value()));
itemProducts = computed(() => {
if (!this.debouncedItemId.value()) return [];
return this.itemProductsResource.value() ?? [];
});
displayedColumns = ['name', 'quantity', 'salePrice', 'printInBill', 'action'];
constructor() {
effect(() => {
const x = this.formModel().addRow?.itemId;
if (typeof x === 'string') {
this.itemProduct.set(null);
}
});
effect(() => {
const menuCategoriesVal = this.menuCategoriesResource.value();
if (menuCategoriesVal) this.menuCategories.set(menuCategoriesVal);
const saleCategoriesVal = this.saleCategoriesResource.value();
if (saleCategoriesVal) this.saleCategories.set(saleCategoriesVal);
const itemVal = this.itemResource.value();
if (itemVal) {
this.item = itemVal;
this.itemProduct.set(null);
this.items.set(this.item.items ?? []);
this.recalcBundleSalePrice(); this.recalcBundleSalePrice();
} }
});
}
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.set(product);
this.formModel.update((f) => ({
...f,
addRow: {
...f.addRow,
salePrice: 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));
this.formModel.update((f) => ({ ...f, salePrice: this.item.salePrice }));
}
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => { setTimeout(() => {
@@ -193,17 +217,25 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
} }
resetAddRow() { resetAddRow() {
this.form.controls.addRow.reset(); this.formModel.update((f) => ({
this.itemProduct = null; ...f,
addRow: {
itemId: '',
quantity: 1,
salePrice: 0,
printInBill: true,
},
}));
this.itemProduct.set(null);
} }
addRow() { addRow() {
const v = this.form.value.addRow; const v = this.formModel().addRow;
if (!v) { if (!v) {
return; return;
} }
if (!this.itemProduct?.id) { if (!this.itemProduct()?.id) {
this.snackBar.open('Please select a product', 'Error'); this.snackBar.open('Please select a product', 'Error');
return; return;
} }
@@ -223,15 +255,15 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
// name is expected from backend as "name (units)" on GET; // name is expected from backend as "name (units)" on GET;
// for new rows we keep it blank (or you can set placeholder) // for new rows we keep it blank (or you can set placeholder)
const bi = new BundleItem({ const bi = new BundleItem({
itemId: this.itemProduct?.id, itemId: this.itemProduct()?.id,
name: this.itemProduct?.name ?? '', name: this.itemProduct()?.name ?? '',
quantity, quantity,
salePrice, salePrice,
printInBill: v.printInBill || false, printInBill: v.printInBill || false,
}); });
this.item.items.push(bi); this.item.items.push(bi);
this.items$.next(this.item.items); this.items.set(this.item.items);
this.resetAddRow(); this.resetAddRow();
this.recalcBundleSalePrice(); this.recalcBundleSalePrice();
@@ -250,7 +282,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
return; return;
} }
Object.assign(row, result as BundleItem); Object.assign(row, result as BundleItem);
this.items$.next(this.item.items); this.items.set(this.item.items);
this.resetAddRow(); this.resetAddRow();
this.recalcBundleSalePrice(); this.recalcBundleSalePrice();
}); });
@@ -258,7 +290,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
deleteRow(row: BundleItem) { deleteRow(row: BundleItem) {
this.item.items.splice(this.item.items.indexOf(row), 1); this.item.items.splice(this.item.items.indexOf(row), 1);
this.items$.next(this.item.items); this.items.set(this.item.items);
this.recalcBundleSalePrice(); this.recalcBundleSalePrice();
} }
@@ -272,8 +304,8 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles'); this.router.navigateByUrl('/bundles');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -284,8 +316,8 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/bundles'); this.router.navigateByUrl('/bundles');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -304,7 +336,7 @@ export class BundleDetailComponent implements OnInit, AfterViewInit {
} }
getItem(): Bundle { getItem(): Bundle {
const v = this.form.value; const v = this.formModel();
this.item.name = v.name ?? ''; this.item.name = v.name ?? '';
this.item.units = v.units ?? ''; this.item.units = v.units ?? '';
@@ -1,15 +0,0 @@
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();
}));
});
@@ -1,9 +0,0 @@
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();
};
@@ -1,67 +0,0 @@
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;
});
}
}
@@ -1,6 +1,6 @@
<h2 class="row-container space-between"> <h2 class="row-container space-between">
<span>Bundles</span> <span>Bundles</span>
<button mat-icon-button (click)="exportCsv()" [disabled]="!(data | async)?.length"> <button type="button" mat-icon-button (click)="exportCsv()" [disabled]="!dataSource().length">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
<a mat-button [routerLink]="['/bundles', 'new']"> <a mat-button [routerLink]="['/bundles', 'new']">
@@ -9,18 +9,18 @@
</a> </a>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Filter</mat-label> <mat-label>Filter</mat-label>
<input type="text" matInput formControlName="filter" autocomplete="off" /> <input type="text" matInput [formField]="form.filter" autocomplete="off" />
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Menu Category</mat-label> <mat-label>Menu Category</mat-label>
<mat-select formControlName="menuCategory" (selectionChange)="filterOn($event.value)"> <mat-select [formField]="form.menuCategory" (selectionChange)="filterOn($event.value)">
<mat-option>-- All Bundles --</mat-option> <mat-option>-- All Bundles --</mat-option>
@for (mc of menuCategories; track mc) { @for (mc of menuCategoriesResource.value(); track mc) {
<mat-option [value]="mc.id"> <mat-option [value]="mc.id">
{{ mc.name }} {{ mc.name }}
</mat-option> </mat-option>
@@ -30,7 +30,7 @@
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" aria-label="Bundles"> <mat-table #table [dataSource]="dataSource()" aria-label="Bundles">
<!-- Name --> <!-- Name -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
@@ -1,6 +1,6 @@
import { AsyncPipe, CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, signal, debounced } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -8,21 +8,23 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { MenuCategory } from '../../core/menu-category'; import { MenuCategoryService } from '../../menu-category/menu-category.service';
import { ToCsvService } from '../../shared/to-csv.service'; import { ToCsvService } from '../../shared/to-csv.service';
import { Bundle } from '../bundle'; import { Bundle } from '../bundle';
import { BundleListDataSource } from './bundle-list-datasource'; import { BundleService } from '../bundle.service';
export interface BundleListFormData {
menuCategory: string | null;
filter: string;
}
@Component({ @Component({
selector: 'app-bundle-list', selector: 'app-bundle-list',
templateUrl: './bundle-list.component.html', templateUrl: './bundle-list.component.html',
styleUrls: ['./bundle-list.component.css'], styleUrls: ['./bundle-list.component.css'],
imports: [ imports: [
AsyncPipe,
CurrencyPipe, CurrencyPipe,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
@@ -31,58 +33,44 @@ import { BundleListDataSource } from './bundle-list-datasource';
MatOptionModule, MatOptionModule,
MatSelectModule, MatSelectModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
RouterModule, RouterModule,
], ],
}) })
export class BundleListComponent implements OnInit { export class BundleListComponent {
private route = inject(ActivatedRoute); private menuCategoryService = inject(MenuCategoryService);
private bundleService = inject(BundleService);
private toCsv = inject(ToCsvService); private toCsv = inject(ToCsvService);
searchFilter = new BehaviorSubject<string>(''); menuCategoriesResource = this.menuCategoryService.list(undefined);
menuCategoryFilter = new BehaviorSubject<string>(''); listResource = this.bundleService.list();
data: BehaviorSubject<Bundle[]> = new BehaviorSubject<Bundle[]>([]);
dataSource: BundleListDataSource = new BundleListDataSource(this.searchFilter, this.menuCategoryFilter, this.data);
form: FormGroup<{ menuCategoryFilter = signal<string>('');
menuCategory: FormControl<string | null>;
filter: FormControl<string>; formModel = signal<BundleListFormData>({
}>; menuCategory: '',
filter: '',
});
form = form(this.formModel);
filterSignal = computed(() => this.formModel().filter);
debouncedFilter = debounced(this.filterSignal, 150);
dataSource = computed(() => {
let items = this.listResource.value() ?? [];
const search = this.debouncedFilter.value()?.toLowerCase() ?? '';
const cat = this.menuCategoryFilter();
if (search) items = items.filter((x: Bundle) => x.name.toLowerCase().includes(search));
if (cat) items = items.filter((x: Bundle) => x.menuCategory?.id === cat);
return items;
});
list: Bundle[] = [];
menuCategories: MenuCategory[] = [];
displayedColumns: string[] = ['name', 'price', 'menuCategory', 'saleCategory', 'info', 'items']; displayedColumns: string[] = ['name', 'price', 'menuCategory', 'saleCategory', '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) { filterOn(val: string) {
this.menuCategoryFilter.next(val); this.menuCategoryFilter.set(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() { exportCsv() {
@@ -96,7 +84,7 @@ export class BundleListComponent implements OnInit {
}; };
// Map items to count for csv (simple) // Map items to count for csv (simple)
const csvList = this.dataSource.filteredData.map((b) => ({ const csvList = this.dataSource().map((b: Bundle) => ({
...b, ...b,
menuCategory: b.menuCategory?.name ?? '', menuCategory: b.menuCategory?.name ?? '',
items: (b.items ?? []) items: (b.items ?? [])
@@ -1,15 +0,0 @@
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
@@ -1,10 +0,0 @@
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);
};
+9 -11
View File
@@ -1,5 +1,5 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -18,17 +18,15 @@ export class BundleService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
list(): Observable<Bundle[]> { list(): HttpResourceRef<Bundle[] | undefined> {
return this.http return httpResource<Bundle[]>(() => `${url}/list`);
.get<Bundle[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Bundle[]>;
} }
get(id: string | null): Observable<Bundle> { get(id: Signal<string | null>): HttpResourceRef<Bundle | undefined> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`; return httpResource<Bundle>(() => {
return this.http const idVal = id();
.get<Bundle>(getUrl) return idVal === null ? url : `${url}/${idVal}`;
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Bundle>; });
} }
save(bundle: Bundle): Observable<void> { save(bundle: Bundle): Observable<void> {
-18
View File
@@ -1,12 +1,8 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { menuCategoryListResolver } from '../menu-category/menu-category-list.resolver';
import { saleCategoryListResolver } from '../sale-category/sale-category-list.resolver';
import { BundleDetailComponent } from './bundle-detail/bundle-detail.component'; import { BundleDetailComponent } from './bundle-detail/bundle-detail.component';
import { bundleListResolver } from './bundle-list.resolver';
import { BundleListComponent } from './bundle-list/bundle-list.component'; import { BundleListComponent } from './bundle-list/bundle-list.component';
import { bundleResolver } from './bundle.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -14,31 +10,17 @@ export const routes: Routes = [
component: BundleListComponent, component: BundleListComponent,
canActivate: [authGuard], canActivate: [authGuard],
data: { permission: 'Products' }, // change if you have a separate permission data: { permission: 'Products' }, // change if you have a separate permission
resolve: {
list: bundleListResolver,
menuCategories: menuCategoryListResolver,
},
}, },
{ {
path: 'new', path: 'new',
component: BundleDetailComponent, component: BundleDetailComponent,
canActivate: [authGuard], canActivate: [authGuard],
data: { permission: 'Products' }, data: { permission: 'Products' },
resolve: {
item: bundleResolver, // returns new Bundle() because id === null (no param here)
menuCategories: menuCategoryListResolver,
saleCategories: saleCategoryListResolver,
},
}, },
{ {
path: ':id', path: ':id',
component: BundleDetailComponent, component: BundleDetailComponent,
canActivate: [authGuard], canActivate: [authGuard],
data: { permission: 'Products' }, data: { permission: 'Products' },
resolve: {
item: bundleResolver,
menuCategories: menuCategoryListResolver,
saleCategories: saleCategoryListResolver,
},
}, },
]; ];
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { ActiveCashiersResolver } from './active-cashiers-resolver.service';
describe('ActiveCashiersResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ActiveCashiersResolver],
});
});
it('should be created', inject([ActiveCashiersResolver], (service: ActiveCashiersResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,11 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { User } from '../core/user';
import { CashierReportService } from './cashier-report.service';
export const activeCashiersResolver: ResolveFn<User[]> = (route) => {
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
return inject(CashierReportService).activeCashiers(startDate, finishDate);
};
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable, of as observableOf } from 'rxjs';
import { CashierReportDisplayItem } from './cashier-report-display-item';
export class CashierReportDataSource extends DataSource<CashierReportDisplayItem> {
constructor(public data: CashierReportDisplayItem[]) {
super();
}
connect(): Observable<CashierReportDisplayItem[]> {
return observableOf(this.data);
}
disconnect() {}
}
@@ -1,15 +1,15 @@
<h2 class="row-container space-between"> <h2 class="row-container space-between">
<span>Cashier Report</span> <span>Cashier Report</span>
<span class="spacer"></span> <span class="spacer"></span>
<button mat-icon-button [disabled]="!info.cashier.id" (click)="exportCsv()"> <button type="button" mat-icon-button [disabled]="!info().cashier.id" (click)="exportCsv()">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
<button mat-icon-button [disabled]="!info.cashier.id" (click)="print()"> <button type="button" mat-icon-button [disabled]="!info().cashier.id" (click)="print()">
<mat-icon>print</mat-icon> <mat-icon>print</mat-icon>
</button> </button>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Start Date</mat-label> <mat-label>Start Date</mat-label>
@@ -17,7 +17,7 @@
matInput matInput
[matDatepicker]="startDate" [matDatepicker]="startDate"
(focus)="startDate.open()" (focus)="startDate.open()"
formControlName="startDate" [formField]="form.startDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
@@ -29,7 +29,7 @@
matInput matInput
[matDatepicker]="finishDate" [matDatepicker]="finishDate"
(focus)="finishDate.open()" (focus)="finishDate.open()"
formControlName="finishDate" [formField]="form.finishDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
@@ -39,19 +39,19 @@
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-4-5"> <mat-form-field class="flex-auto basis-4-5">
<mat-label>Cashier</mat-label> <mat-label>Cashier</mat-label>
<mat-select formControlName="cashier"> <mat-select [formField]="form.cashier">
<mat-option>-- Cashier --</mat-option> <mat-option>-- Cashier --</mat-option>
@for (ac of activeCashiers; track ac) { @for (ac of cashiers(); track ac) {
<mat-option [value]="ac.id"> <mat-option [value]="ac.id">
{{ ac.name }} {{ ac.name }}
</mat-option> </mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<button mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button> <button type="button" mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button>
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <mat-table #table [dataSource]="dataSource()" aria-label="Elements">
<!-- Name Column --> <!-- Name Column -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
@@ -1,6 +1,6 @@
import { CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
@@ -16,9 +16,14 @@ import moment from 'moment';
import { User } from '../core/user'; import { User } from '../core/user';
import { ToCsvService } from '../shared/to-csv.service'; import { ToCsvService } from '../shared/to-csv.service';
import { CashierReport } from './cashier-report'; import { CashierReport } from './cashier-report';
import { CashierReportDataSource } from './cashier-report-datasource';
import { CashierReportService } from './cashier-report.service'; import { CashierReportService } from './cashier-report.service';
export interface CashierReportFormData {
startDate: Date;
finishDate: Date;
cashier: string | null;
}
@Component({ @Component({
selector: 'app-cashier-report', selector: 'app-cashier-report',
templateUrl: './cashier-report.component.html', templateUrl: './cashier-report.component.html',
@@ -26,7 +31,6 @@ import { CashierReportService } from './cashier-report.service';
imports: [ imports: [
CurrencyPipe, CurrencyPipe,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
@@ -34,51 +38,49 @@ import { CashierReportService } from './cashier-report.service';
MatOptionModule, MatOptionModule,
MatSelectModule, MatSelectModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class CashierReportComponent implements OnInit { export class CashierReportComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private toCsv = inject(ToCsvService); private toCsv = inject(ToCsvService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(CashierReportService); private ser = inject(CashierReportService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
startDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
finishDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
activeCashiers: User[] = []; infoResource = this.ser.list(this.id, this.startDate, this.finishDate);
info: CashierReport = new CashierReport(); cashiersResource = this.ser.activeCashiers(this.startDate, this.finishDate);
dataSource: CashierReportDataSource = new CashierReportDataSource(this.info.amounts);
form: FormGroup<{ cashiers = computed(() => (this.cashiersResource.value() ?? []) as User[]);
startDate: FormControl<Date>;
finishDate: FormControl<Date>; info = computed(() => this.infoResource.value() ?? new CashierReport());
cashier: FormControl<string | null>; dataSource = computed(() => this.info().amounts);
}>; formModel = linkedSignal({
source: this.info,
computation: (infoVal): CashierReportFormData => {
if (infoVal) {
return {
cashier: this.info().cashier.id ?? '',
startDate: moment(this.info().startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info().finishDate, 'DD-MMM-YYYY').toDate(),
};
}
return {
startDate: new Date(),
finishDate: new Date(),
cashier: null,
};
},
});
form = form(this.formModel);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'amount']; displayedColumns = ['name', 'amount'];
constructor() {
// Create form
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
cashier: new FormControl<string | null>(null),
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { cashiers: User[]; info: CashierReport };
this.activeCashiers = data.cashiers;
this.info = data.info;
this.form.setValue({
cashier: this.info.cashier.id ?? '',
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
});
this.dataSource = new CashierReportDataSource(this.info.amounts);
});
}
show() { show() {
const info = this.getInfo(); const info = this.getInfo();
const url = ['cashier-report']; const url = ['cashier-report'];
@@ -94,22 +96,22 @@ export class CashierReportComponent implements OnInit {
} }
print() { print() {
this.ser.print(this.info.cashier.id as string, this.info.startDate, this.info.finishDate).subscribe({ this.ser.print(this.info().cashier.id as string, this.info().startDate, this.info().finishDate).subscribe({
next: () => { next: () => {
this.snackBar.open('Successfully Printed', ''); this.snackBar.open('Successfully Printed', '');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
getInfo(): CashierReport { getInfo(): CashierReport {
const formModel = this.form.value; const formModel = this.formModel();
return new CashierReport({ return new CashierReport({
cashier: new User({ id: formModel.cashier ?? undefined }), cashier: new User({ id: formModel.cashier ?? undefined }),
cashiers: this.info.cashiers, cashiers: this.info().cashiers,
startDate: moment(formModel.startDate).format('DD-MMM-YYYY'), startDate: moment(formModel.startDate).format('DD-MMM-YYYY'),
finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'), finishDate: moment(formModel.finishDate).format('DD-MMM-YYYY'),
}); });
@@ -120,7 +122,7 @@ export class CashierReportComponent implements OnInit {
Name: 'name', Name: 'name',
Amount: 'amount', Amount: 'amount',
}; };
const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource.data)], { const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource())], {
type: 'text/csv;charset=utf-8;', type: 'text/csv;charset=utf-8;',
}); });
const link = document.createElement('a'); const link = document.createElement('a');
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { CashierReportResolver } from './cashier-report-resolver.service';
describe('CashierReportResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CashierReportResolver],
});
});
it('should be created', inject([CashierReportResolver], (service: CashierReportResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,12 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { CashierReport } from './cashier-report';
import { CashierReportService } from './cashier-report.service';
export const cashierReportResolver: ResolveFn<CashierReport> = (route) => {
const id = route.paramMap.get('id');
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
return inject(CashierReportService).list(id, startDate, finishDate);
};
@@ -1,9 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { activeCashiersResolver } from './active-cashiers.resolver';
import { CashierReportComponent } from './cashier-report.component'; import { CashierReportComponent } from './cashier-report.component';
import { cashierReportResolver } from './cashier-report.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -13,10 +11,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Cashier Report', permission: 'Cashier Report',
}, },
resolve: {
info: cashierReportResolver,
cashiers: activeCashiersResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
@@ -26,10 +20,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Cashier Report', permission: 'Cashier Report',
}, },
resolve: {
info: cashierReportResolver,
cashiers: activeCashiersResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
]; ];
@@ -1,5 +1,5 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -17,32 +17,43 @@ export class CashierReportService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
list(id: string | null, startDate: string | null, finishDate: string | null): Observable<CashierReport> { list(
const listUrl = id === null ? url : `${url}/${id}`; id: Signal<string | null>,
const options = { params: new HttpParams() }; startDate: Signal<string | null>,
if (startDate !== null) { finishDate: Signal<string | null>,
options.params = options.params.set('s', startDate); ): HttpResourceRef<CashierReport | undefined> {
return httpResource<CashierReport>(() => {
const idVal = id();
const listUrl = idVal === null ? url : `${url}/${idVal}`;
let params = new HttpParams();
const s = startDate();
const f = finishDate();
if (s !== null) {
params = params.set('s', s);
} }
if (finishDate !== null) { if (f !== null) {
options.params = options.params.set('f', finishDate); params = params.set('f', f);
} }
return this.http return { url: listUrl, params };
.get<CashierReport>(listUrl, options) });
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<CashierReport>;
} }
activeCashiers(startDate: string | null, finishDate: string | null): Observable<User[]> { activeCashiers(
const options = { params: new HttpParams() }; startDate: Signal<string | null>,
if (startDate !== null) { finishDate: Signal<string | null>,
options.params = options.params.set('s', startDate); ): HttpResourceRef<User[] | undefined> {
return httpResource<User[]>(() => {
let params = new HttpParams();
const s = startDate();
const f = finishDate();
if (s !== null) {
params = params.set('s', s);
} }
if (finishDate !== null) { if (f !== null) {
options.params = options.params.set('f', finishDate); params = params.set('f', f);
} }
return this.http return { url: `${url}/active`, params };
.get<User[]>(`${url}/active`, options) });
.pipe(catchError(this.log.handleError(serviceName, 'activeCashiers'))) as Observable<User[]>;
} }
print(id: string, startDate: string | null, finishDate: string | null): Observable<boolean> { print(id: string, startDate: string | null, finishDate: string | null): Observable<boolean> {
+12
View File
@@ -0,0 +1,12 @@
export class UserRole {
id: string | undefined;
name: string;
enabled: boolean;
public constructor(init?: Partial<UserRole>) {
this.id = undefined;
this.name = '';
this.enabled = false;
Object.assign(this, init);
}
}
@@ -1,39 +1,41 @@
<h2>Customer</h2> <h2>Customer</h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input matInput #nameElement formControlName="name" /> <input matInput #nameElement [formField]="form.name" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Phone</mat-label> <mat-label>Phone</mat-label>
<input matInput formControlName="phone" /> <input matInput [formField]="form.phone" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Address</mat-label> <mat-label>Address</mat-label>
<textarea matInput formControlName="address"> </textarea> <textarea matInput [formField]="form.address"> </textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-checkbox formControlName="printInBill">Print in Bill?</mat-checkbox> <mat-checkbox [formField]="form.printInBill">Print in Bill?</mat-checkbox>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div formArrayName="discounts" class="discount-grid"> <div class="discounts">
@for (r of item.discounts; track r; let i = $index) { @for (r of item().discounts; track r; let i = $index) {
<mat-form-field [formGroupName]="i"> <div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Discount on {{ r.name }}</mat-label> <mat-label>Discount on {{ r.name }}</mat-label>
<input matInput formControlName="discount" /> <input matInput type="number" [formField]="form.discounts[i].discount" />
<span matTextSuffix>%</span> <span matTextSuffix>%</span>
<mat-hint>Max {{ r.limit | percent: '1.2-2' }}</mat-hint> <mat-hint>Max {{ r.limit | percent: '1.2-2' }}</mat-hint>
</mat-form-field> </mat-form-field>
</div>
} }
</div> </div>
</form> </form>
<div class="row-container"> <div class="row-container">
<button mat-raised-button color="primary" class="" (click)="save()">Save</button> <button type="button" mat-raised-button color="primary" class="" (click)="save()">Save</button>
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button> <button type="button" mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
</div> </div>
@@ -1,6 +1,6 @@
import { PercentPipe } from '@angular/common'; import { PercentPipe } from '@angular/common';
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, ViewChild, computed, inject, linkedSignal, input } from '@angular/core';
import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -15,6 +15,16 @@ import { Customer } from '../../core/customer';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { CustomerService } from '../customer.service'; import { CustomerService } from '../customer.service';
export interface CustomerDetailFormData {
name: string;
phone: string;
address: string;
printInBill: boolean;
discounts: {
discount: number | null;
}[];
}
@Component({ @Component({
selector: 'app-customer-detail', selector: 'app-customer-detail',
templateUrl: './customer-detail.component.html', templateUrl: './customer-detail.component.html',
@@ -26,66 +36,37 @@ import { CustomerService } from '../customer.service';
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
PercentPipe, PercentPipe,
ReactiveFormsModule, FormField,
], ],
}) })
export class CustomerDetailComponent implements OnInit, AfterViewInit { export class CustomerDetailComponent implements AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private ser = inject(CustomerService); private ser = inject(CustomerService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef; itemResource = this.ser.get(this.id);
form: FormGroup<{
name: FormControl<string>;
phone: FormControl<string>;
address: FormControl<string | null>;
printInBill: FormControl<boolean>;
discounts: FormArray<
FormGroup<{
discount: FormControl<number | null>;
}>
>;
}>;
item: Customer = new Customer(); item = computed(() => this.itemResource.value() ?? new Customer());
hide: boolean; formModel = linkedSignal({
source: this.item,
constructor() { computation: (itemVal): CustomerDetailFormData => {
this.hide = true; return {
// Create form name: itemVal.name ?? '',
this.form = new FormGroup({ phone: itemVal.phone ?? '',
name: new FormControl<string>('', { nonNullable: true }), address: itemVal.address ?? '',
phone: new FormControl<string>('', { nonNullable: true }), printInBill: itemVal.printInBill ?? false,
address: new FormControl<string | null>(null), discounts: itemVal.discounts.map((x) => ({ discount: x.discount ? x.discount * 100 : null })),
printInBill: new FormControl<boolean>(false, { nonNullable: true }), };
discounts: new FormArray<FormGroup<{ discount: FormControl<number | null> }>>([]), },
}); });
}
ngOnInit() { form = form(this.formModel);
this.route.data.subscribe((value) => {
const data = value as { item: Customer };
this.showItem(data.item);
});
}
showItem(item: Customer) { hide = true;
this.item = item;
this.form.controls.name.setValue(item.name);
this.form.controls.phone.setValue(item.phone);
this.form.controls.address.setValue(item.address);
this.form.controls.printInBill.setValue(item.printInBill);
this.form.controls.discounts.clear();
this.item.discounts.forEach((x) =>
this.form.controls.discounts.push(
new FormGroup({
discount: new FormControl<number | null>(x.discount ? x.discount * 100 : null),
}),
),
);
}
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => { setTimeout(() => {
@@ -101,20 +82,20 @@ export class CustomerDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/customers'); this.router.navigateByUrl('/customers');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
delete() { delete() {
this.ser.delete(this.item.id as string).subscribe({ this.ser.delete(this.item().id as string).subscribe({
next: () => { next: () => {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/customers'); this.router.navigateByUrl('/customers');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -133,23 +114,20 @@ export class CustomerDetailComponent implements OnInit, AfterViewInit {
} }
getItem(): Customer { getItem(): Customer {
const formModel = this.form.value; const formModel = this.formModel();
this.item.name = formModel.name ?? ''; return new Customer({
this.item.phone = formModel.phone ?? ''; ...this.item(),
this.item.address = formModel.address ?? null; name: formModel.name ?? '',
this.item.printInBill = formModel.printInBill ?? false; phone: formModel.phone ?? '',
address: formModel.address ?? null,
const array = formModel.discounts; printInBill: formModel.printInBill ?? false,
if (array) { discounts: this.item().discounts.map((item, index) => {
this.item.discounts.forEach((item, index) => { const discount = formModel.discounts[index]?.discount;
const array_item = array.at(index); return {
if (array_item && array_item?.discount) { ...item,
item.discount = Math.max(Math.min(round(array_item.discount / 100, 5), 100), 0); discount: discount ? Math.max(Math.min(round(discount / 100, 5), 100), 0) : 0,
} else { };
item.discount = 0; }),
}
}); });
} }
return this.item;
}
} }
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { CustomerListResolver } from './customer-list-resolver.service';
describe('CustomerListResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CustomerListResolver],
});
});
it('should be created', inject([CustomerListResolver], (service: CustomerListResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,15 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Customer } from '../core/customer';
import { PagedResult } from '../core/paged-result';
import { CustomerService } from './customer.service';
export const customerListResolver: ResolveFn<PagedResult<Customer>> = (route) => {
const q = route.queryParamMap.get('q');
const page = Number(route.queryParamMap.get('p') ?? 0);
const size = Number(route.queryParamMap.get('s') ?? 50);
const field = route.queryParamMap.get('f') ?? 'name';
const direction = route.queryParamMap.get('d') ?? 'asc';
return inject(CustomerService).list(q, page, size, field, direction);
};
@@ -1,161 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Observable, of as observableOf } from 'rxjs';
import { Customer } from '../../core/customer';
import { PagedResult } from '../../core/paged-result';
export class CustomerListDatasource extends DataSource<Customer> {
constructor(
public data: PagedResult<Customer>,
private paginator?: MatPaginator,
private sort?: MatSort,
) {
super();
}
connect(): Observable<Customer[]> {
if (this.paginator) {
this.paginator.length = this.data.total;
if (this.data.pageSize !== undefined) {
this.paginator.pageSize = this.data.pageSize;
}
if (this.data.page !== undefined) {
this.paginator.pageIndex = this.data.page;
}
}
if (this.sort) {
if (this.data.field !== undefined) {
this.sort.active = this.data.field;
}
if (this.data.sortDirection !== undefined) {
this.sort.direction = this.data.sortDirection;
}
}
return observableOf(this.data.items);
}
disconnect() {}
}
// import { DataSource } from '@angular/cdk/collections';
// import { map, merge, Observable, of as observableOf, tap } from 'rxjs';
// import { Customer } from '../../core/customer';
// import { MatPaginator, PageEvent } from '@angular/material/paginator';
// import { MatSort, Sort } from '@angular/material/sort';
// import { EventEmitter } from '@angular/core';
// export class CustomerListDatasource extends DataSource<Customer> {
// private filterValue = '';
// constructor(public data: Customer[],
// private readonly filter: Observable<string>,
// private paginator?: MatPaginator,
// private sort?: MatSort,
// ) {
// super();
// this.filter = filter.pipe(
// tap((x) => {
// this.filterValue = x;
// }),
// );
// }
// connect(): Observable<Customer[]> {
// const dataMutations: (EventEmitter<PageEvent> | EventEmitter<Sort>)[] = [];
// if (this.paginator) {
// dataMutations.push((this.paginator as MatPaginator).page);
// }
// if (this.sort) {
// dataMutations.push((this.sort as MatSort).sortChange);
// }
// return merge(observableOf(this.data), this.filter, ...dataMutations).pipe(
// map(() => this.getFilteredData([...this.data])),
// tap((x: Customer[]) => {
// if (this.paginator) {
// this.paginator.length = x.length;
// }
// }),
// map((x: Customer[]) => this.getPagedData(this.getSortedData(x))),
// );
// }
// disconnect() {}
// private getFilteredData(data: Customer[]): Customer[] {
// return this.filterValue.toLowerCase().split(' ').reduce(
// (p: Customer[], c: string) =>
// p.filter((x) => {
// const productString = `${x.name} ${x.phone}${x.address}`.toLowerCase();
// return productString.indexOf(c) !== -1;
// }),
// Object.assign([], data),
// );
// }
// private getPagedData(data: Customer[]) {
// if (this.paginator === undefined) {
// return data;
// }
// const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
// return data.splice(startIndex, this.paginator.pageSize);
// }
// private getSortedData(data: Customer[]) {
// if (this.sort === undefined) {
// return data;
// }
// if (!this.sort.active || this.sort.direction === '') {
// return data;
// }
// const sort = this.sort as MatSort;
// console.log('Sorting by', sort.active, sort.direction);
// return data.sort(sortBy(sort.active as keyof Customer, sort.direction === 'asc'));
// }
// }
// function compareValues<T>(a: T, b: T, isAsc: boolean): number {
// if (a === b) return 0;
// if (a == null) return isAsc ? -1 : 1;
// if (b == null) return isAsc ? 1 : -1;
// if (typeof a === "string" && typeof b === "string") {
// return isAsc ? a.localeCompare(b) : b.localeCompare(a);
// }
// if (typeof a === "number" && typeof b === "number") {
// return isAsc ? a - b : b - a;
// }
// if (typeof a === "boolean" && typeof b === "boolean") {
// return isAsc ? (Number(a) - Number(b)) : (Number(b) - Number(a));
// }
// // Fallback
// const aStr = String(a);
// const bStr = String(b);
// return isAsc ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
// }
// function sortBy<T, K extends keyof T>(
// key: K,
// isAsc = true
// ): (a: T, b: T) => number {
// return (a, b) => compareValues(a[key], b[key], isAsc);
// }
// // function getNestedValue<T>(obj: T, path: string | string[]): any {
// // const keys = Array.isArray(path) ? path : path.split(".");
// // return keys.reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
// // }
// // function sortByNested<T>(
// // path: string | string[],
// // isAsc = true
// // ): (a: T, b: T) => number {
// // return (a, b) => {
// // const aValue = getNestedValue(a, path);
// // const bValue = getNestedValue(b, path);
// // return compareValues(aValue, bValue, isAsc);
// // };
// // }
@@ -5,15 +5,15 @@
Add Add
</a> </a>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Filter</mat-label> <mat-label>Filter</mat-label>
<input type="text" matInput #filterElement formControlName="filter" autocomplete="off" /> <input type="text" matInput #filterElement [formField]="form.filter" autocomplete="off" />
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements"> <mat-table #table [dataSource]="dataSource().items" matSort aria-label="Elements">
<!-- Name Column --> <!-- Name Column -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
@@ -57,7 +57,7 @@
</mat-table> </mat-table>
<mat-paginator <mat-paginator
#paginator #paginator
[length]="dataSource.data.total" [length]="dataSource().total"
[pageIndex]="0" [pageIndex]="0"
[pageSize]="50" [pageSize]="50"
[pageSizeOptions]="[25, 50, 100, 250, 5000]" [pageSizeOptions]="[25, 50, 100, 250, 5000]"
@@ -1,6 +1,16 @@
import { PercentPipe } from '@angular/common'; import { PercentPipe } from '@angular/common';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject, AfterViewInit } from '@angular/core'; import {
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; AfterViewInit,
Component,
ElementRef,
ViewChild,
inject,
signal,
effect,
computed,
debounced,
} from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@@ -8,11 +18,13 @@ import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { debounceTime, distinctUntilChanged, Observable } from 'rxjs';
import { Customer } from '../../core/customer'; import { Customer } from '../../core/customer';
import { PagedResult } from '../../core/paged-result'; import { CustomerService } from '../customer.service';
import { CustomerListDatasource } from './customer-list-datasource';
export interface CustomerListFormData {
filter: string;
}
@Component({ @Component({
selector: 'app-customer-list', selector: 'app-customer-list',
@@ -20,7 +32,6 @@ import { CustomerListDatasource } from './customer-list-datasource';
styleUrls: ['./customer-list.component.css'], styleUrls: ['./customer-list.component.css'],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatTableModule, MatTableModule,
PercentPipe, PercentPipe,
@@ -28,76 +39,75 @@ import { CustomerListDatasource } from './customer-list-datasource';
MatInputModule, MatInputModule,
MatPaginatorModule, MatPaginatorModule,
MatSortModule, MatSortModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class CustomerListComponent implements OnInit, AfterViewInit { export class CustomerListComponent implements AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private cdr = inject(ChangeDetectorRef);
@ViewChild('filterElement', { static: true }) filterElement!: ElementRef<HTMLInputElement>; @ViewChild('filterElement', { static: true }) filterElement!: ElementRef<HTMLInputElement>;
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator; @ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort; @ViewChild(MatSort, { static: true }) sort!: MatSort;
private route = inject(ActivatedRoute);
private router = inject(Router);
private customerService = inject(CustomerService);
data: PagedResult<Customer> = new PagedResult<Customer>([], 0, 0, 0); formModel = signal<CustomerListFormData>({
filter: Observable<string>; filter: this.route.snapshot.queryParamMap.get('q') ?? '',
dataSource: CustomerListDatasource; });
form: FormGroup<{
filter: FormControl<string>; form = form(this.formModel);
}>;
filterSignal = computed(() => this.formModel().filter);
debouncedFilter = debounced(this.filterSignal, 150);
pageIndex = signal(+(this.route.snapshot.queryParamMap.get('p') ?? '0'));
pageSize = signal(+(this.route.snapshot.queryParamMap.get('s') ?? '50'));
sortField = signal(this.route.snapshot.queryParamMap.get('f') ?? 'name');
sortDirection = signal(this.route.snapshot.queryParamMap.get('d') ?? 'asc');
queryParams = computed(() => ({
q: this.debouncedFilter.value() ?? '',
p: this.pageIndex(),
s: this.pageSize(),
f: this.sortField(),
d: this.sortDirection(),
}));
listResource = this.customerService.query(this.queryParams);
dataSource = computed(() => {
const data = this.listResource.value();
if (!data) return { items: [], total: 0 };
data.items.forEach((c: Customer) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
return data;
});
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts']; displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts'];
constructor() { constructor() {
this.form = new FormGroup({ // Sync query string whenever params change
filter: new FormControl<string>('', { nonNullable: true }), effect(() => {
}); const p = this.queryParams();
this.filter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
}
ngOnInit() {
const q = this.route.snapshot.queryParamMap.get('q');
if (q) {
this.form.controls.filter.setValue(q, { emitEvent: false }); // Prevent infinite loop
}
this.route.data.subscribe((value) => {
const data = value as { data: PagedResult<Customer> };
data.data.items.forEach((c: Customer) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.data = data.data;
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
this.cdr.detectChanges();
});
this.sort.sortChange.subscribe({
next: () =>
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { f: this.sort?.active ?? 'name', d: this.sort?.direction }, queryParams: { q: p.q, p: p.p, s: p.s, f: p.f, d: p.d },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
}), replaceUrl: true,
}); });
this.paginator.page.subscribe({
next: () =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { s: this.paginator.pageSize, p: this.paginator.pageIndex },
queryParamsHandling: 'merge',
}),
});
this.filter.subscribe({
next: (value) => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { q: value },
queryParamsHandling: 'merge',
});
},
}); });
} }
ngAfterViewInit() { ngAfterViewInit() {
this.sort.sortChange.subscribe(() => {
this.sortField.set(this.sort.active);
this.sortDirection.set(this.sort.direction);
this.pageIndex.set(0);
});
this.paginator.page.subscribe(() => {
this.pageSize.set(this.paginator.pageSize);
this.pageIndex.set(this.paginator.pageIndex);
});
setTimeout(() => { setTimeout(() => {
this.filterElement.nativeElement.focus(); this.filterElement.nativeElement.focus();
}, 0); }, 0);
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { CustomerResolver } from './customer-resolver.service';
describe('CustomerResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CustomerResolver],
});
});
it('should be created', inject([CustomerResolver], (service: CustomerResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,10 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Customer } from '../core/customer';
import { CustomerService } from './customer.service';
export const customerResolver: ResolveFn<Customer> = (route) => {
const id = route.paramMap.get('id');
return inject(CustomerService).get(id);
};
@@ -2,9 +2,7 @@ import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { CustomerDetailComponent } from './customer-detail/customer-detail.component'; import { CustomerDetailComponent } from './customer-detail/customer-detail.component';
import { customerListResolver } from './customer-list.resolver';
import { CustomerListComponent } from './customer-list/customer-list.component'; import { CustomerListComponent } from './customer-list/customer-list.component';
import { customerResolver } from './customer.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -14,9 +12,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Customers', permission: 'Customers',
}, },
resolve: {
data: customerListResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
@@ -26,16 +21,10 @@ export const routes: Routes = [
data: { data: {
permission: 'Customers', permission: 'Customers',
}, },
resolve: {
item: customerResolver,
},
}, },
{ {
path: ':id', path: ':id',
component: CustomerDetailComponent, component: CustomerDetailComponent,
canActivate: [authGuard], canActivate: [authGuard],
resolve: {
item: customerResolver,
},
}, },
]; ];
+30 -13
View File
@@ -1,5 +1,5 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -20,11 +20,11 @@ export class CustomerService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
get(id: string | null): Observable<Customer> { get(id: Signal<string | null> | string | null): HttpResourceRef<Customer | undefined> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`; return httpResource<Customer>(() => {
return this.http const idVal = typeof id === 'function' ? id() : id;
.get<Customer>(getUrl) return idVal === null || idVal === undefined ? url : `${url}/${idVal}`;
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Customer>; });
} }
list(q: string | null, page = 0, size = 50, field = 'name', direction = 'asc'): Observable<PagedResult<Customer>> { list(q: string | null, page = 0, size = 50, field = 'name', direction = 'asc'): Observable<PagedResult<Customer>> {
@@ -35,12 +35,29 @@ export class CustomerService {
f: field, f: field,
d: direction, d: direction,
}; };
console.log('CustomerService.list', params);
return this.http return this.http
.get<PagedResult<Customer>>(`${url}/list`, { params }) .get<PagedResult<Customer>>(`${url}/list`, { params })
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<PagedResult<Customer>>; .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<PagedResult<Customer>>;
} }
query(
params: Signal<{ q: string; p: number; s: number; f: string; d: string }>,
): HttpResourceRef<PagedResult<Customer> | undefined> {
return httpResource<PagedResult<Customer>>(() => {
const p = params();
return {
url: `${url}/list`,
params: {
...(p.q ? { q: p.q } : {}),
p: p.p,
s: p.s,
f: p.f,
d: p.d,
},
};
});
}
save(customer: Customer): Observable<Customer> { save(customer: Customer): Observable<Customer> {
return this.http return this.http
.post<Customer>(`${url}`, customer, httpOptions) .post<Customer>(`${url}`, customer, httpOptions)
@@ -66,10 +83,10 @@ export class CustomerService {
.pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<Customer>; .pipe(catchError(this.log.handleError(serviceName, 'delete'))) as Observable<Customer>;
} }
autocomplete(query: string): Observable<Customer[]> { autocomplete(query: Signal<string | null> | string | null): HttpResourceRef<Customer[] | undefined> {
const options = { params: new HttpParams().set('q', query) }; return httpResource<Customer[]>(() => {
return this.http const qVal = typeof query === 'function' ? query() : query;
.get<Customer[]>(`${url}/query`, options) return `${url}/query?q=${qVal ?? ''}`;
.pipe(catchError(this.log.handleError(serviceName, 'autocomplete'))) as Observable<Customer[]>; });
} }
} }
@@ -1,16 +1,16 @@
<h2>Device</h2> <h2>Device</h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input matInput #nameElement formControlName="name" (keyup.enter)="save()" /> <input matInput #nameElement [formField]="form.name" (keyup.enter)="save()" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Section</mat-label> <mat-label>Section</mat-label>
<mat-select formControlName="section"> <mat-select [formField]="form.section">
@for (s of sections; track s) { @for (s of sections(); track s) {
<mat-option [value]="s.id"> <mat-option [value]="s.id">
{{ s.name }} {{ s.name }}
</mat-option> </mat-option>
@@ -19,16 +19,16 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-checkbox formControlName="enabled" class="flex-auto">Enabled?</mat-checkbox> <mat-checkbox [formField]="form.enabled" class="flex-auto">Enabled?</mat-checkbox>
</div> </div>
</form> </form>
<div class="row-container space-between"> <div class="row-container space-between">
<button mat-raised-button color="primary" class="" (click)="save()">Save</button> <button type="button" mat-raised-button color="primary" class="" (click)="save()">Save</button>
@if (!!item.id) { @if (!!item().id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button> <button type="button" mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
} }
<div class="spacer"></div> <div class="spacer"></div>
<span <span
>Created on <strong>{{ item.creationDate | localTime }}</strong></span >Created on <strong>{{ item().creationDate | localTime }}</strong></span
> >
</div> </div>
@@ -1,5 +1,5 @@
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, ViewChild, computed, inject, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
@@ -12,10 +12,17 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Device } from '../../core/device'; import { Device } from '../../core/device';
import { Section } from '../../core/section'; import { Section } from '../../core/section';
import { SectionService } from '../../sections/section.service';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { LocalTimePipe } from '../../shared/local-time.pipe'; import { LocalTimePipe } from '../../shared/local-time.pipe';
import { DeviceService } from '../device.service'; import { DeviceService } from '../device.service';
export interface DeviceDetailFormData {
name: string;
section: string | null;
enabled: boolean;
}
@Component({ @Component({
selector: 'app-device-detail', selector: 'app-device-detail',
templateUrl: './device-detail.component.html', templateUrl: './device-detail.component.html',
@@ -23,58 +30,43 @@ import { DeviceService } from '../device.service';
imports: [ imports: [
LocalTimePipe, LocalTimePipe,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatOptionModule, MatOptionModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class DeviceDetailComponent implements OnInit, AfterViewInit { export class DeviceDetailComponent implements AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef;
private sectionService = inject(SectionService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(DeviceService); private ser = inject(DeviceService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef; sectionsResource = this.sectionService.list();
form: FormGroup<{
name: FormControl<string>;
section: FormControl<string | null>;
enabled: FormControl<boolean>;
}>;
sections: Section[] = []; itemResource = this.ser.get(this.id);
item: Device = new Device();
constructor() { item = computed(() => this.itemResource.value() ?? new Device());
// Create form formModel = linkedSignal({
this.form = new FormGroup({ source: this.item,
name: new FormControl<string>('', { nonNullable: true }), computation: (itemVal): DeviceDetailFormData => {
section: new FormControl<string | null>(null), return {
enabled: new FormControl<boolean>(false, { nonNullable: true }), name: itemVal.name ?? '',
section: itemVal.section.id ? itemVal.section.id : '',
enabled: itemVal.enabled,
};
},
}); });
}
ngOnInit() { form = form(this.formModel);
this.route.data.subscribe((value) => {
const data = value as { item: Device; sections: Section[] };
this.showItem(data.item);
this.sections = data.sections;
});
}
showItem(item: Device) { sections = computed(() => this.sectionsResource.value() ?? []);
this.item = item;
this.form.setValue({
name: this.item.name ?? '',
section: this.item.section.id ? this.item.section.id : '',
enabled: this.item.enabled,
});
}
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => { setTimeout(() => {
@@ -90,20 +82,20 @@ export class DeviceDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/devices'); this.router.navigateByUrl('/devices');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
delete() { delete() {
this.ser.delete(this.item.id as string).subscribe({ this.ser.delete(this.item().id as string).subscribe({
next: () => { next: () => {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/devices'); this.router.navigateByUrl('/devices');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -122,10 +114,12 @@ export class DeviceDetailComponent implements OnInit, AfterViewInit {
} }
getItem(): Device { getItem(): Device {
const formModel = this.form.value; const formModel = this.formModel();
this.item.name = formModel.name ?? ''; return new Device({
this.item.section.id = formModel.section ?? ''; ...this.item(),
this.item.enabled = formModel.enabled ?? true; name: formModel.name ?? '',
return this.item; section: new Section({ id: formModel.section ?? '' }),
enabled: formModel.enabled ?? true,
});
} }
} }
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { DeviceListResolver } from './device-list-resolver.service';
describe('DeviceListResolverService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DeviceListResolver],
});
});
it('should be created', inject([DeviceListResolver], (service: DeviceListResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,9 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Device } from '../core/device';
import { DeviceService } from './device.service';
export const deviceListResolver: ResolveFn<Device[]> = () => {
return inject(DeviceService).list();
};
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable, of as observableOf } from 'rxjs';
import { Device } from '../../core/device';
export class DeviceListDataSource extends DataSource<Device> {
constructor(public data: Device[]) {
super();
}
connect(): Observable<Device[]> {
return observableOf(this.data);
}
disconnect() {}
}
@@ -5,7 +5,7 @@
Add Add
</a> </a>
</h2> </h2>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <mat-table #table [dataSource]="dataSource()" aria-label="Elements">
<!-- Name Column --> <!-- Name Column -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
@@ -1,32 +1,23 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { Device } from '../../core/device';
import { LocalTimePipe } from '../../shared/local-time.pipe'; import { LocalTimePipe } from '../../shared/local-time.pipe';
import { DeviceListDataSource } from './device-list-datasource'; import { DeviceService } from '../device.service';
@Component({ @Component({
selector: 'app-device-list', selector: 'app-device-list',
templateUrl: './device-list.component.html', templateUrl: './device-list.component.html',
styleUrls: ['./device-list.component.css'], styleUrls: ['./device-list.component.css'],
imports: [LocalTimePipe, MatButtonModule, MatIconModule, MatTableModule, RouterLink], imports: [LocalTimePipe, MatButtonModule, MatIconModule, MatTableModule, RouterLink],
}) })
export class DeviceListComponent implements OnInit { export class DeviceListComponent {
private deviceService = inject(DeviceService);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
listResource = this.deviceService.list();
list: Device[] = []; dataSource = computed(() => this.listResource.value() ?? []);
dataSource: DeviceListDataSource = new DeviceListDataSource(this.list);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'section', 'enabled', 'creationDate', 'last']; displayedColumns = ['name', 'section', 'enabled', 'creationDate', 'last'];
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { list: Device[] };
this.list = data.list;
});
this.dataSource = new DeviceListDataSource(this.list);
}
} }
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { DeviceResolver } from './device-resolver.service';
describe('DeviceResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DeviceResolver],
});
});
it('should be created', inject([DeviceResolver], (service: DeviceResolver) => {
expect(service).toBeTruthy();
}));
});
-10
View File
@@ -1,10 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Device } from '../core/device';
import { DeviceService } from './device.service';
export const deviceResolver: ResolveFn<Device> = (route) => {
const id = route.paramMap.get('id');
return inject(DeviceService).get(id);
};
+9 -11
View File
@@ -1,5 +1,5 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -16,17 +16,15 @@ export class DeviceService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
get(id: string | null): Observable<Device> { get(id: Signal<string | null> | string | null): HttpResourceRef<Device | undefined> {
const getUrl: string = id === null ? `${url}` : `${url}/${id}`; return httpResource<Device>(() => {
return this.http const idVal = typeof id === 'function' ? id() : id;
.get<Device>(getUrl) return idVal === null || idVal === undefined ? url : `${url}/${idVal}`;
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Device>; });
} }
list(): Observable<Device[]> { list(): HttpResourceRef<Device[] | undefined> {
return this.http return httpResource<Device[]>(() => `${url}/list`);
.get<Device[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Device[]>;
} }
save(device: Device): Observable<Device> { save(device: Device): Observable<Device> {
-14
View File
@@ -1,11 +1,8 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { sectionListResolver } from '../sections/section-list.resolver';
import { DeviceDetailComponent } from './device-detail/device-detail.component'; import { DeviceDetailComponent } from './device-detail/device-detail.component';
import { deviceListResolver } from './device-list.resolver';
import { DeviceListComponent } from './device-list/device-list.component'; import { DeviceListComponent } from './device-list/device-list.component';
import { deviceResolver } from './device.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -15,9 +12,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Devices', permission: 'Devices',
}, },
resolve: {
list: deviceListResolver,
},
}, },
{ {
path: 'new', path: 'new',
@@ -26,10 +20,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Devices', permission: 'Devices',
}, },
resolve: {
item: deviceResolver,
sections: sectionListResolver,
},
}, },
{ {
path: ':id', path: ':id',
@@ -38,9 +28,5 @@ export const routes: Routes = [
data: { data: {
permission: 'Devices', permission: 'Devices',
}, },
resolve: {
item: deviceResolver,
sections: sectionListResolver,
},
}, },
]; ];
@@ -1,16 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable, of as observableOf } from 'rxjs';
import { DiscountReportItem } from './discount-report-item';
export class DiscountReportDataSource extends DataSource<DiscountReportItem> {
constructor(public data: DiscountReportItem[]) {
super();
}
connect(): Observable<DiscountReportItem[]> {
return observableOf(this.data);
}
disconnect() {}
}
@@ -1,14 +1,14 @@
<h2 class="row-container space-between"> <h2 class="row-container space-between">
<span>Discount Report</span> <span>Discount Report</span>
<span class="spacer"></span> <span class="spacer"></span>
<button mat-icon-button (click)="exportCsv()"> <button type="button" mat-icon-button (click)="exportCsv()">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
<button mat-icon-button (click)="print()"> <button type="button" mat-icon-button (click)="print()">
<mat-icon>print</mat-icon> <mat-icon>print</mat-icon>
</button> </button>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container sm:max-lg:flex-col"> <div class="row-container sm:max-lg:flex-col">
<mat-form-field class="flex-auto basis-2-5"> <mat-form-field class="flex-auto basis-2-5">
<mat-label>Start Date</mat-label> <mat-label>Start Date</mat-label>
@@ -16,7 +16,7 @@
matInput matInput
[matDatepicker]="startDate" [matDatepicker]="startDate"
(focus)="startDate.open()" (focus)="startDate.open()"
formControlName="startDate" [formField]="form.startDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
@@ -28,16 +28,16 @@
matInput matInput
[matDatepicker]="finishDate" [matDatepicker]="finishDate"
(focus)="finishDate.open()" (focus)="finishDate.open()"
formControlName="finishDate" [formField]="form.finishDate"
autocomplete="off" autocomplete="off"
/> />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
<mat-datepicker #finishDate></mat-datepicker> <mat-datepicker #finishDate></mat-datepicker>
</mat-form-field> </mat-form-field>
<button mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button> <button type="button" mat-raised-button class="flex-auto basis-1-5" color="primary" (click)="show()">Show</button>
</div> </div>
</form> </form>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <mat-table #table [dataSource]="dataSource()" aria-label="Elements">
<!-- Name Column --> <!-- Name Column -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef>Name</mat-header-cell>
@@ -1,6 +1,6 @@
import { CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -13,9 +13,13 @@ import moment from 'moment';
import { ToCsvService } from '../shared/to-csv.service'; import { ToCsvService } from '../shared/to-csv.service';
import { DiscountReport } from './discount-report'; import { DiscountReport } from './discount-report';
import { DiscountReportDataSource } from './discount-report-datasource';
import { DiscountReportService } from './discount-report.service'; import { DiscountReportService } from './discount-report.service';
export interface DiscountReportFormData {
startDate: Date;
finishDate: Date;
}
@Component({ @Component({
selector: 'app-discount-report', selector: 'app-discount-report',
templateUrl: './discount-report.component.html', templateUrl: './discount-report.component.html',
@@ -23,52 +27,48 @@ import { DiscountReportService } from './discount-report.service';
imports: [ imports: [
CurrencyPipe, CurrencyPipe,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatInputModule, MatInputModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class DiscountReportComponent implements OnInit { export class DiscountReportComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private toCsv = inject(ToCsvService); private toCsv = inject(ToCsvService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(DiscountReportService); private ser = inject(DiscountReportService);
startDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
finishDate = input(null, { transform: (v: string | null | undefined) => v ?? null });
info: DiscountReport = new DiscountReport(); infoResource = this.ser.get(this.startDate, this.finishDate);
dataSource: DiscountReportDataSource = new DiscountReportDataSource(this.info.amounts);
form: FormGroup<{ info = computed(() => this.infoResource.value() ?? new DiscountReport());
startDate: FormControl<Date>; dataSource = computed(() => this.info().amounts);
finishDate: FormControl<Date>; formModel = linkedSignal({
}>; source: this.info,
computation: (infoVal): DiscountReportFormData => {
if (infoVal) {
return {
startDate: moment(this.info().startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info().finishDate, 'DD-MMM-YYYY').toDate(),
};
}
return {
startDate: new Date(),
finishDate: new Date(),
};
},
});
form = form(this.formModel);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'amount']; displayedColumns = ['name', 'amount'];
constructor() {
// Create form
this.form = new FormGroup({
startDate: new FormControl(new Date(), { nonNullable: true }),
finishDate: new FormControl(new Date(), { nonNullable: true }),
});
}
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { info: DiscountReport };
this.info = data.info;
this.form.setValue({
startDate: moment(this.info.startDate, 'DD-MMM-YYYY').toDate(),
finishDate: moment(this.info.finishDate, 'DD-MMM-YYYY').toDate(),
});
this.dataSource = new DiscountReportDataSource(this.info.amounts);
});
}
show() { show() {
const info = this.getInfo(); const info = this.getInfo();
this.router.navigate(['discount-report'], { this.router.navigate(['discount-report'], {
@@ -80,7 +80,7 @@ export class DiscountReportComponent implements OnInit {
} }
getInfo(): DiscountReport { getInfo(): DiscountReport {
const formModel = this.form.value; const formModel = this.formModel();
return new DiscountReport({ return new DiscountReport({
startDate: moment(formModel.startDate).format('DD-MMM-YYYY'), startDate: moment(formModel.startDate).format('DD-MMM-YYYY'),
@@ -89,12 +89,12 @@ export class DiscountReportComponent implements OnInit {
} }
print() { print() {
this.ser.print(this.info.startDate, this.info.finishDate).subscribe({ this.ser.print(this.info().startDate, this.info().finishDate).subscribe({
next: () => { next: () => {
this.snackBar.open('Successfully Printed', ''); this.snackBar.open('Successfully Printed', '');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -104,7 +104,7 @@ export class DiscountReportComponent implements OnInit {
Name: 'name', Name: 'name',
Amount: 'amount', Amount: 'amount',
}; };
const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource.data)], { const csvData = new Blob([this.toCsv.toCsv(headers, this.dataSource())], {
type: 'text/csv;charset=utf-8;', type: 'text/csv;charset=utf-8;',
}); });
const link = document.createElement('a'); const link = document.createElement('a');
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { DiscountReportResolver } from './discount-report-resolver.service';
describe('DiscountReportResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DiscountReportResolver],
});
});
it('should be created', inject([DiscountReportResolver], (service: DiscountReportResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,11 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { DiscountReport } from './discount-report';
import { DiscountReportService } from './discount-report.service';
export const discountReportResolver: ResolveFn<DiscountReport> = (route) => {
const startDate = route.queryParamMap.get('startDate') ?? null;
const finishDate = route.queryParamMap.get('finishDate') ?? null;
return inject(DiscountReportService).get(startDate, finishDate);
};
@@ -2,7 +2,6 @@ import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { DiscountReportComponent } from './discount-report.component'; import { DiscountReportComponent } from './discount-report.component';
import { discountReportResolver } from './discount-report.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -12,9 +11,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Discount Report', permission: 'Discount Report',
}, },
resolve: {
info: discountReportResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
]; ];
@@ -1,5 +1,5 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -16,17 +16,22 @@ export class DiscountReportService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
get(startDate: string | null, finishDate: string | null): Observable<DiscountReport> { get(
const options = { params: new HttpParams() }; startDate: Signal<string | null>,
if (startDate !== null) { finishDate: Signal<string | null>,
options.params = options.params.set('s', startDate); ): HttpResourceRef<DiscountReport | undefined> {
return httpResource<DiscountReport>(() => {
let params = new HttpParams();
const s = startDate();
const f = finishDate();
if (s !== null) {
params = params.set('s', s);
} }
if (finishDate !== null) { if (f !== null) {
options.params = options.params.set('f', finishDate); params = params.set('f', f);
} }
return this.http return { url, params };
.get<DiscountReport>(url, options) });
.pipe(catchError(this.log.handleError(serviceName, 'get'))) as Observable<DiscountReport>;
} }
print(startDate: string | null, finishDate: string | null): Observable<boolean> { print(startDate: string | null, finishDate: string | null): Observable<boolean> {
@@ -1,11 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { GuestBook } from './guest-book';
import { GuestBookService } from './guest-book.service';
export const guestBookDetailResolver: ResolveFn<GuestBook> = (route) => {
const id = route.paramMap.get('id');
const account = route.queryParamMap.get('t') || 'walk_in';
return inject(GuestBookService).get(id, account);
};
@@ -1,18 +1,15 @@
<h2>Guest Details</h2> <h2>Guest Details</h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input matInput #name formControlName="name" /> <input matInput #name [formField]="form.name" />
@if (form.controls['name'].hasError('required')) {
<mat-error> Name is <strong>required</strong> </mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Phone</mat-label> <mat-label>Phone</mat-label>
<input matInput #phone type="text" formControlName="phone" [matAutocomplete]="auto" autocomplete="off" /> <input matInput #phone type="text" [formField]="form.phone" [matAutocomplete]="auto" autocomplete="off" />
</mat-form-field> </mat-form-field>
<mat-autocomplete <mat-autocomplete
#auto="matAutocomplete" #auto="matAutocomplete"
@@ -20,7 +17,7 @@
[displayWith]="displayFn" [displayWith]="displayFn"
(optionSelected)="selected($event)" (optionSelected)="selected($event)"
> >
@for (customer of customers | async; track customer) { @for (customer of customers(); track customer) {
<mat-option [value]="customer">{{ customer.name }} - {{ customer.phone }}</mat-option> <mat-option [value]="customer">{{ customer.name }} - {{ customer.phone }}</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>
@@ -28,42 +25,46 @@
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Pax</mat-label> <mat-label>Pax</mat-label>
<input matInput type="number" formControlName="pax" /> <input matInput type="number" [formField]="form.pax" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Address</mat-label> <mat-label>Address</mat-label>
<textarea matInput formControlName="address"></textarea> <textarea matInput [formField]="form.address"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Notes</mat-label> <mat-label>Notes</mat-label>
<textarea matInput formControlName="notes"></textarea> <textarea matInput [formField]="form.notes"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto basis-1-2"> <mat-form-field class="flex-auto basis-1-2">
<mat-label>Date</mat-label> <mat-label>Date</mat-label>
<input matInput [matDatepicker]="date" (focus)="date.open()" formControlName="date" autocomplete="off" /> <input matInput [matDatepicker]="date" (focus)="date.open()" [formField]="form.date" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
<mat-datepicker #date></mat-datepicker> <mat-datepicker #date></mat-datepicker>
</mat-form-field> </mat-form-field>
<mat-form-field class="flex-auto basis-1-4"> <mat-form-field class="flex-auto basis-1-2">
<mat-label>Hours</mat-label> <mat-label>Time</mat-label>
<input matInput formControlName="hours" /> <input
</mat-form-field> matInput
<mat-form-field class="flex-auto basis-1-4"> [matTimepicker]="time"
<mat-label>Minutes</mat-label> [formField]="form.time"
<input matInput formControlName="minutes" /> matTimepickerMin="12:00"
matTimepickerMax="23:45"
/>
<mat-timepicker-toggle matSuffix [for]="time"></mat-timepicker-toggle>
<mat-timepicker #time interval="15min"></mat-timepicker>
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
<div class="row-container"> <div class="row-container">
<button mat-raised-button color="primary" class="" (click)="save()">Save</button> <button type="button" mat-raised-button color="primary" class="" (click)="save()">Save</button>
@if (!!item.id) { @if (!!item().id) {
<button mat-raised-button color="warn" (click)="confirmDelete()">Delete</button> <button type="button" mat-raised-button color="warn" (click)="confirmDelete()">Delete</button>
} }
</div> </div>
@@ -1,6 +1,15 @@
import { AsyncPipe } from '@angular/common'; import {
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; AfterViewInit,
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; Component,
ElementRef,
ViewChild,
computed,
inject,
debounced,
linkedSignal,
input,
} from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
import { MatAutocompleteSelectedEvent, MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent, MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
@@ -9,88 +18,98 @@ import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTimepickerModule } from '@angular/material/timepicker';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import moment from 'moment'; import moment from 'moment';
import { Observable, of as observableOf } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import { Customer } from '../../core/customer'; import { Customer } from '../../core/customer';
import { CustomerService } from '../../customers/customer.service'; import { CustomerService } from '../../customers/customer.service';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { GuestBook } from '../guest-book'; import { GuestBook, GuestBookType } from '../guest-book';
import { GuestBookService } from '../guest-book.service'; import { GuestBookService } from '../guest-book.service';
export interface GuestBookDetailFormData {
name: string;
phone: string | Customer;
pax: number;
address: string;
notes: string;
date: Date;
time: Date;
}
@Component({ @Component({
selector: 'app-guest-book-detail', selector: 'app-guest-book-detail',
templateUrl: './guest-book-detail.component.html', templateUrl: './guest-book-detail.component.html',
styleUrls: ['./guest-book-detail.component.css'], styleUrls: ['./guest-book-detail.component.css'],
imports: [ imports: [
AsyncPipe,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatOptionModule, MatOptionModule,
ReactiveFormsModule, MatTimepickerModule,
FormField,
], ],
}) })
export class GuestBookDetailComponent implements OnInit, AfterViewInit { export class GuestBookDetailComponent implements AfterViewInit {
@ViewChild('phone', { static: true }) phoneElement?: ElementRef;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(GuestBookService); private ser = inject(GuestBookService);
private customerService = inject(CustomerService); private customerService = inject(CustomerService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
@ViewChild('phone', { static: true }) phoneElement?: ElementRef; t = input<GuestBookType, string | null | undefined>('walk_in', {
form: FormGroup<{ transform: (v: string | null | undefined) => (v as GuestBookType) ?? 'walk_in',
name: FormControl<string>;
phone: FormControl<string | Customer>;
pax: FormControl<number>;
address: FormControl<string | null>;
notes: FormControl<string | null>;
date: FormControl<Date>;
hours: FormControl<number>;
minutes: FormControl<number>;
}>;
item: GuestBook = new GuestBook();
customers: Observable<Customer[]>;
constructor() {
// Create form
this.form = new FormGroup({
name: new FormControl('', { validators: Validators.required, nonNullable: true }),
phone: new FormControl<string | Customer>('', {
validators: Validators.required,
nonNullable: true,
}),
pax: new FormControl(0, { validators: Validators.required, nonNullable: true }),
address: new FormControl<string | null>(null),
notes: new FormControl<string | null>(null),
date: new FormControl({ value: new Date(), disabled: false }, { nonNullable: true }),
hours: new FormControl(0, { validators: [Validators.min(0), Validators.max(23)], nonNullable: true }),
minutes: new FormControl(0, { validators: [Validators.min(0), Validators.max(59)], nonNullable: true }),
}); });
// Setup Account Autocomplete
this.customers = this.form.controls.phone.valueChanges.pipe(
startWith(null),
map((x) => (x !== null && (x as string).length >= 1 ? x : null)),
debounceTime(150),
distinctUntilChanged(),
switchMap((x) => (x === null ? observableOf([]) : this.customerService.autocomplete(x as string))),
);
}
ngOnInit() { itemResource = this.ser.get(this.id, this.t);
this.route.data.subscribe((value) => {
const data = value as { item: GuestBook }; item = computed(() => this.itemResource.value() ?? new GuestBook());
this.showItem(data.item);
}); formModel = linkedSignal({
source: this.item,
computation: (itemVal): GuestBookDetailFormData => {
if (itemVal && itemVal.id) {
const dt = (itemVal.arrivalDate || itemVal.bookingDate) as string;
return {
name: itemVal.name,
phone: itemVal.phone,
pax: itemVal.pax,
address: itemVal.address ?? '',
notes: itemVal.notes ?? '',
date: dt ? new Date(dt) : new Date(),
time: dt ? new Date(dt) : new Date(),
};
} }
return {
name: '',
phone: '',
pax: 0,
address: '',
notes: '',
date: new Date(),
time: new Date(),
};
},
});
form = form(this.formModel);
phoneSignal = computed(() => {
const p = this.formModel().phone;
return typeof p === 'string' ? p.trim() : (p as Customer).phone;
});
debouncedPhone = debounced(this.phoneSignal, 150);
customersResource = this.customerService.autocomplete(computed(() => this.debouncedPhone.value()));
customers = computed(() => {
if (!this.debouncedPhone.value()) return [];
return this.customersResource.value() ?? [];
});
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => { setTimeout(() => {
@@ -100,35 +119,14 @@ export class GuestBookDetailComponent implements OnInit, AfterViewInit {
}, 0); }, 0);
} }
showItem(item: GuestBook) {
this.item = item;
const dt = (this.item.arrivalDate || this.item.bookingDate) as string;
if (item.type === 'booking') {
this.form.controls.date.enable();
} else {
this.form.controls.date.disable();
}
this.form.setValue({
name: item.name,
phone: item.phone,
pax: item.pax,
address: item.address,
notes: item.notes,
date: new Date(dt),
hours: +dt.substring(12, 14),
minutes: +dt.substring(15, 17),
});
}
save() { save() {
this.ser.saveOrUpdate(this.getItem()).subscribe({ this.ser.saveOrUpdate(this.getItem()).subscribe({
next: () => { next: () => {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/guest-book'); this.router.navigateByUrl('/guest-book');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -139,11 +137,12 @@ export class GuestBookDetailComponent implements OnInit, AfterViewInit {
selected(event: MatAutocompleteSelectedEvent): void { selected(event: MatAutocompleteSelectedEvent): void {
const customer = event.option.value; const customer = event.option.value;
this.form.patchValue({ this.formModel.update((f) => ({
...f,
name: customer.name, name: customer.name,
// phone: customer.phone, // phone: customer.phone,
address: customer.address, address: customer.address,
}); }));
} }
confirmDelete(): void { confirmDelete(): void {
@@ -154,7 +153,7 @@ export class GuestBookDetailComponent implements OnInit, AfterViewInit {
dialogRef.afterClosed().subscribe((result: boolean) => { dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) { if (result) {
this.delete(this.item.id ?? ''); this.delete(this.item().id ?? '');
} }
}); });
} }
@@ -165,32 +164,36 @@ export class GuestBookDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/guest-book'); this.router.navigateByUrl('/guest-book');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
getItem(): GuestBook { getItem(): GuestBook {
const formModel = this.form.value; const formModel = this.formModel();
this.item.name = formModel.name ?? ''; const item = Object.assign(new GuestBook(), this.item());
item.name = formModel.name ?? '';
if (typeof formModel.phone === 'string') { if (typeof formModel.phone === 'string') {
this.item.phone = formModel.phone; item.phone = formModel.phone;
} else { } else {
this.item.phone = (formModel.phone as Customer).phone; item.phone = (formModel.phone as Customer).phone;
} }
this.item.pax = formModel.pax ?? 0; item.pax = formModel.pax ?? 0;
this.item.address = formModel.address ?? null; item.address = formModel.address;
this.item.notes = formModel.notes ?? null; item.notes = formModel.notes;
const hours = ('' + (formModel.hours as number)).padStart(2, '0'); const timeStr = formModel.time ? moment(formModel.time).format('HH:mm') : '00:00';
const minutes = ('' + (formModel.minutes as number)).padStart(2, '0'); item.type = item.type || this.t() || 'walk_in';
if (this.item.type === 'booking') {
console.log(formModel.date); if (item.type === 'booking') {
this.item.bookingDate = moment(formModel.date).format('DD-MMM-YYYY') + ' ' + hours + ':' + minutes; item.bookingDate = moment(formModel.date).format('DD-MMM-YYYY') + ' ' + timeStr;
} else { } else {
this.item.arrivalDate = (this.item.arrivalDate as string).substring(0, 12) + hours + ':' + minutes; const existingDate = item.arrivalDate
? (item.arrivalDate as string).substring(0, 12)
: moment(formModel.date).format('DD-MMM-YYYY ');
item.arrivalDate = existingDate + timeStr;
} }
return this.item; return item;
} }
} }
@@ -1,9 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { GuestBookList } from './guest-book-list';
import { GuestBookService } from './guest-book.service';
export const guestBookListResolver: ResolveFn<GuestBookList> = () => {
return inject(GuestBookService).list(null);
};
@@ -1,20 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { GuestBook } from '../guest-book';
export class GuestBookListDataSource extends DataSource<GuestBook> {
constructor(private readonly dataObs: Observable<GuestBook[]>) {
super();
}
connect(): Observable<GuestBook[]> {
return this.dataObs;
}
/**
* Called when the table is being destroyed. Use this function, to clean up
* any open connections or free any held resources that were set up during connect.
*/
disconnect() {}
}
@@ -10,17 +10,17 @@
</a> </a>
</h2> </h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Date</mat-label> <mat-label>Date</mat-label>
<input matInput [matDatepicker]="date" (focus)="date.open()" formControlName="date" autocomplete="off" /> <input matInput [matDatepicker]="date" (focus)="date.open()" [formField]="form.date" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="date"></mat-datepicker-toggle>
<mat-datepicker #date></mat-datepicker> <mat-datepicker #date></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
<mat-table [dataSource]="dataSource" aria-label="Elements"> <mat-table [dataSource]="dataSource()" aria-label="Elements">
<!-- SNo Column --> <!-- SNo Column -->
<ng-container matColumnDef="sno"> <ng-container matColumnDef="sno">
<mat-header-cell *matHeaderCellDef>S. No / Total</mat-header-cell> <mat-header-cell *matHeaderCellDef>S. No / Total</mat-header-cell>
@@ -1,5 +1,5 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, inject, computed, effect, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -9,14 +9,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import moment from 'moment'; import moment from 'moment';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';
import { LocalTimePipe } from '../../shared/local-time.pipe'; import { LocalTimePipe } from '../../shared/local-time.pipe';
import { GuestBook } from '../guest-book';
import { GuestBookList } from '../guest-book-list';
import { GuestBookService } from '../guest-book.service'; import { GuestBookService } from '../guest-book.service';
import { GuestBookListDataSource } from './guest-book-list-datasource';
export interface GuestBookListFormData {
date: Date;
}
@Component({ @Component({
selector: 'app-guest-book-list', selector: 'app-guest-book-list',
@@ -26,53 +25,53 @@ import { GuestBookListDataSource } from './guest-book-list-datasource';
LocalTimePipe, LocalTimePipe,
MatButtonModule, MatButtonModule,
MatButtonModule, MatButtonModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatIconModule, MatIconModule,
MatInputModule, MatInputModule,
MatTableModule, MatTableModule,
ReactiveFormsModule, FormField,
RouterLink, RouterLink,
], ],
}) })
export class GuestBookListComponent implements OnInit { export class GuestBookListComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(GuestBookService); private ser = inject(GuestBookService);
data: BehaviorSubject<GuestBook[]> = new BehaviorSubject<GuestBook[]>([]); formModel = signal<GuestBookListFormData>({
dataSource: GuestBookListDataSource = new GuestBookListDataSource(this.data); date: this.initialDate(),
form: FormGroup<{ });
date: FormControl<Date>;
}>; form = form(this.formModel);
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['sno', 'name', 'phone', 'pax', 'date', 'action']; displayedColumns = ['sno', 'name', 'phone', 'pax', 'date', 'action'];
dateSignal = computed(() => moment(this.formModel().date).format('DD-MMM-YYYY'));
listResource = this.ser.list(this.dateSignal);
dataSource = computed(() => this.listResource.value()?.list ?? []);
constructor() { constructor() {
// Create form // Keep the query string in sync with the form date
this.form = new FormGroup({ effect(() => {
date: new FormControl(new Date(), { nonNullable: true }), const date = this.dateSignal();
this.router.navigate([], {
relativeTo: this.route,
queryParams: { date },
queryParamsHandling: 'merge',
replaceUrl: true,
});
}); });
this.listenToDateChange();
} }
listenToDateChange(): void { private initialDate(): Date {
this.form.controls.date.valueChanges.pipe(map((x) => moment(x).format('DD-MMM-YYYY'))).subscribe((x) => const qp = this.route.snapshot.queryParamMap.get('date');
this.ser.list(x).subscribe((list: GuestBookList) => { if (qp) {
this.data.next(list.list); const parsed = moment(qp, 'DD-MMM-YYYY', true);
}), if (parsed.isValid()) return parsed.toDate();
);
} }
return new Date();
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { list: GuestBookList };
this.data.next(data.list.list);
this.form.setValue({ date: moment(data.list.date, 'DD-MMM-YYYY').toDate() });
});
this.dataSource = new GuestBookListDataSource(this.data);
} }
} }
@@ -1,9 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { guestBookDetailResolver } from './guest-book-detail.resolver';
import { GuestBookDetailComponent } from './guest-book-detail/guest-book-detail.component'; import { GuestBookDetailComponent } from './guest-book-detail/guest-book-detail.component';
import { guestBookListResolver } from './guest-book-list.resolver';
import { GuestBookListComponent } from './guest-book-list/guest-book-list.component'; import { GuestBookListComponent } from './guest-book-list/guest-book-list.component';
export const routes: Routes = [ export const routes: Routes = [
@@ -14,9 +12,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Customers', permission: 'Customers',
}, },
resolve: {
list: guestBookListResolver,
},
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
@@ -26,9 +21,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Customers', permission: 'Customers',
}, },
resolve: {
item: guestBookDetailResolver,
},
}, },
{ {
path: ':id', path: ':id',
@@ -37,8 +29,5 @@ export const routes: Routes = [
data: { data: {
permission: 'Customers', permission: 'Customers',
}, },
resolve: {
item: guestBookDetailResolver,
},
}, },
]; ];
+20 -14
View File
@@ -1,10 +1,10 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, Signal, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service'; import { ErrorLoggerService } from '../core/error-logger.service';
import { GuestBook } from './guest-book'; import { GuestBook, GuestBookType } from './guest-book';
import { GuestBookList } from './guest-book-list'; import { GuestBookList } from './guest-book-list';
const httpOptions = { const httpOptions = {
@@ -19,19 +19,25 @@ export class GuestBookService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
get(id: string | null, type: string): Observable<GuestBook> { get(
const options = { params: new HttpParams().set('t', type) }; id: Signal<string | null> | string | null,
const getUrl: string = id === null ? url : `${url}/${id}`; type: Signal<GuestBookType> | GuestBookType | string = '',
return this.http ): HttpResourceRef<GuestBook | undefined> {
.get<GuestBook>(getUrl, options) return httpResource<GuestBook>(() => {
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<GuestBook>; const idVal = typeof id === 'function' ? id() : id;
const typeVal = typeof type === 'function' ? type() : type;
const options = { params: new HttpParams().set('t', typeVal) };
const getUrl: string = idVal === null || idVal === undefined ? url : `${url}/${idVal}`;
return { url: getUrl, ...options };
});
} }
list(date: string | null): Observable<GuestBookList> { list(date: Signal<string | null> | string | null): HttpResourceRef<GuestBookList | undefined> {
const options = { params: new HttpParams().set('q', date === null ? '' : date) }; return httpResource<GuestBookList>(() => {
return this.http const dateVal = typeof date === 'function' ? date() : date;
.get<GuestBookList>(`${url}/list`, options) const options = { params: new HttpParams().set('q', dateVal === null ? '' : dateVal) };
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<GuestBookList>; return { url: `${url}/list`, ...options };
});
} }
save(guestBook: GuestBook): Observable<GuestBook> { save(guestBook: GuestBook): Observable<GuestBook> {
+3 -1
View File
@@ -1,3 +1,5 @@
export type GuestBookType = 'walk_in' | 'booking';
export class GuestBook { export class GuestBook {
id: string | undefined; id: string | undefined;
serial: number; serial: number;
@@ -9,7 +11,7 @@ export class GuestBook {
notes: string | null; notes: string | null;
bookingDate: string | null; bookingDate: string | null;
arrivalDate: string | null; arrivalDate: string | null;
type: string | undefined; type: GuestBookType | undefined;
lastEditDate: string | undefined; lastEditDate: string | undefined;
status?: string; status?: string;
tableId?: string; tableId?: string;
@@ -1,9 +1,9 @@
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Header / Footer</mat-label> <mat-label>Header / Footer</mat-label>
<mat-select formControlName="id" (selectionChange)="show($event)"> <mat-select [formField]="form.id" (selectionChange)="show($event)">
@for (s of list; track s) { @for (s of list(); track s) {
<mat-option [value]="s.id"> <mat-option [value]="s.id">
{{ s.name }} {{ s.name }}
</mat-option> </mat-option>
@@ -14,10 +14,10 @@
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Text</mat-label> <mat-label>Text</mat-label>
<textarea matInput matAutosizeMinRows="5" formControlName="text"></textarea> <textarea matInput matAutosizeMinRows="5" [formField]="form.text"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
<div class="row-container"> <div class="row-container">
<button mat-raised-button color="primary" (click)="save()">Save</button> <button type="button" mat-raised-button color="primary" (click)="save()">Save</button>
</div> </div>
@@ -1,5 +1,5 @@
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { Component, ElementRef, ViewChild, computed, inject, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -9,92 +9,82 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectChange, MatSelectModule } from '@angular/material/select'; import { MatSelectChange, MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs/operators';
import { HeaderFooter } from './header-footer'; import { HeaderFooter } from './header-footer';
import { HeaderFooterService } from './header-footer.service'; import { HeaderFooterService } from './header-footer.service';
export interface HeaderFooterFormData {
id: string;
text: string;
}
@Component({ @Component({
selector: 'app-section-printer', selector: 'app-section-printer',
templateUrl: './header-footer.component.html', templateUrl: './header-footer.component.html',
styleUrls: ['./header-footer.component.css'], styleUrls: ['./header-footer.component.css'],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatDividerModule, MatDividerModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatOptionModule, MatOptionModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule, FormField,
], ],
}) })
export class HeaderFooterComponent implements OnInit { export class HeaderFooterComponent {
@ViewChild('section', { static: true }) sectionElement?: ElementRef;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private ser = inject(HeaderFooterService); private ser = inject(HeaderFooterService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
listResource = this.ser.list();
@ViewChild('section', { static: true }) sectionElement?: ElementRef; list = computed(() => this.listResource.value() ?? []);
form: FormGroup<{
id: FormControl<string>;
text: FormControl<string>;
}>;
list: HeaderFooter[] = []; formModel = linkedSignal({
id = ''; source: () => {
const list = this.list();
constructor() { let currentId = this.id();
const route = this.route; if (!currentId && list.length > 0) {
currentId = list[0].id;
// Create form
this.form = new FormGroup({
id: new FormControl<string>('', { nonNullable: true }),
text: new FormControl<string>('', { nonNullable: true }),
});
route.params.pipe(map((p) => p['id'])).subscribe((x) => {
this.id = x;
});
} }
return { list, id: currentId };
ngOnInit() { },
this.route.data.subscribe((value) => { computation: ({ list, id }): HeaderFooterFormData => {
const data = value as { list: HeaderFooter[] }; const val = list.find((v) => v.id === id);
this.showItem(data.list); return {
id: id ?? '',
text: val?.text ?? '',
};
},
}); });
}
showItem(list: HeaderFooter[]) { form = form(this.formModel);
this.list = list;
this.id = this.id ?? this.list[0].id;
const val = this.list.find((v) => v.id === this.id) as HeaderFooter;
this.form.setValue({
id: this.id,
text: val.text,
});
}
save() { save() {
this.ser.save(this.getItem()).subscribe({ this.ser.save(this.getItem()).subscribe({
next: (result: HeaderFooter[]) => { next: (result: HeaderFooter[]) => {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.showItem(result); this.listResource.value.set(result);
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
getItem(): HeaderFooter { getItem(): HeaderFooter {
const formModel = this.form.value; const formModel = this.formModel();
const item = this.list.find((v) => v.id === this.id); const item = this.list().find((v) => v.id === formModel.id);
if (item === undefined) { if (item === undefined) {
return new HeaderFooter(); return new HeaderFooter();
} }
item.text = formModel.text ?? ''; const clonedItem = Object.assign(new HeaderFooter(), item);
return item; clonedItem.text = formModel.text ?? '';
return clonedItem;
} }
show(val: MatSelectChange) { show(val: MatSelectChange) {
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { HeaderFooterResolver } from './header-footer-resolver.service';
describe('HeaderFooterResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [HeaderFooterResolver],
});
});
it('should be created', inject([HeaderFooterResolver], (service: HeaderFooterResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,9 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { HeaderFooter } from './header-footer';
import { HeaderFooterService } from './header-footer.service';
export const headerFooterResolver: ResolveFn<HeaderFooter[]> = () => {
return inject(HeaderFooterService).list();
};
@@ -2,7 +2,6 @@ import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { HeaderFooterComponent } from './header-footer.component'; import { HeaderFooterComponent } from './header-footer.component';
import { headerFooterResolver } from './header-footer.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -12,9 +11,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Owner', permission: 'Owner',
}, },
resolve: {
list: headerFooterResolver,
},
}, },
{ {
path: ':id', path: ':id',
@@ -23,8 +19,5 @@ export const routes: Routes = [
data: { data: {
permission: 'Owner', permission: 'Owner',
}, },
resolve: {
list: headerFooterResolver,
},
}, },
]; ];
@@ -1,4 +1,4 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders, httpResource, HttpResourceRef } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@@ -19,10 +19,8 @@ export class HeaderFooterService {
private http = inject(HttpClient); private http = inject(HttpClient);
private log = inject(ErrorLoggerService); private log = inject(ErrorLoggerService);
list(): Observable<HeaderFooter[]> { list(): HttpResourceRef<HeaderFooter[] | undefined> {
return this.http.get<HeaderFooter[]>(url).pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable< return httpResource<HeaderFooter[]>(() => url);
HeaderFooter[]
>;
} }
save(item: HeaderFooter): Observable<HeaderFooter[]> { save(item: HeaderFooter): Observable<HeaderFooter[]> {
@@ -2,9 +2,7 @@ import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service'; import { authGuard } from '../auth/auth-guard.service';
import { MenuCategoryDetailComponent } from './menu-category-detail/menu-category-detail.component'; import { MenuCategoryDetailComponent } from './menu-category-detail/menu-category-detail.component';
import { menuCategoryListResolver } from './menu-category-list.resolver';
import { MenuCategoryListComponent } from './menu-category-list/menu-category-list.component'; import { MenuCategoryListComponent } from './menu-category-list/menu-category-list.component';
import { menuCategoryResolver } from './menu-category.resolver';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -14,9 +12,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Products', permission: 'Products',
}, },
resolve: {
list: menuCategoryListResolver,
},
}, },
{ {
path: 'new', path: 'new',
@@ -25,9 +20,6 @@ export const routes: Routes = [
data: { data: {
permission: 'Products', permission: 'Products',
}, },
resolve: {
item: menuCategoryResolver,
},
}, },
{ {
path: ':id', path: ':id',
@@ -36,8 +28,5 @@ export const routes: Routes = [
data: { data: {
permission: 'Products', permission: 'Products',
}, },
resolve: {
item: menuCategoryResolver,
},
}, },
]; ];
@@ -1,19 +1,23 @@
<h2>Menu Category</h2> <h2>Menu Category</h2>
<form [formGroup]="form" class="flex-col"> <form class="flex-col">
<div class="row-container"> <div class="row-container">
<mat-form-field class="flex-auto"> <mat-form-field class="flex-auto">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<input matInput #nameElement formControlName="name" /> <input matInput #nameElement [formField]="form.name" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row-container"> <div class="row-container">
<mat-checkbox formControlName="isActive">Is Active?</mat-checkbox> <mat-checkbox [formField]="form.isActive">Is Active?</mat-checkbox>
</div> </div>
</form> </form>
<div class="row-container"> <div class="row-container">
<button mat-raised-button [disabled]="item.isFixture" color="primary" class="" (click)="save()">Save</button> <button type="button" mat-raised-button [disabled]="item().isFixture" color="primary" class="" (click)="save()">
@if (!!item.id) { Save
<button mat-raised-button [disabled]="item.isFixture" color="warn" (click)="confirmDelete()">Delete</button> </button>
@if (!!item().id) {
<button type="button" mat-raised-button [disabled]="item().isFixture" color="warn" (click)="confirmDelete()">
Delete
</button>
} }
</div> </div>
@@ -1,5 +1,5 @@
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, ViewChild, computed, inject, linkedSignal, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -12,49 +12,40 @@ import { MenuCategory } from '../../core/menu-category';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { MenuCategoryService } from '../menu-category.service'; import { MenuCategoryService } from '../menu-category.service';
export interface MenuCategoryDetailFormData {
name: string;
isActive: boolean;
}
@Component({ @Component({
selector: 'app-menu-category-detail', selector: 'app-menu-category-detail',
templateUrl: './menu-category-detail.component.html', templateUrl: './menu-category-detail.component.html',
styleUrls: ['./menu-category-detail.component.css'], styleUrls: ['./menu-category-detail.component.css'],
imports: [MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule], imports: [MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, FormField],
}) })
export class MenuCategoryDetailComponent implements OnInit, AfterViewInit { export class MenuCategoryDetailComponent implements AfterViewInit {
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private ser = inject(MenuCategoryService); private ser = inject(MenuCategoryService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
@ViewChild('nameElement', { static: true }) nameElement?: ElementRef; itemResource = this.ser.get(this.id);
form: FormGroup<{
name: FormControl<string>;
isActive: FormControl<boolean>;
}>;
item: MenuCategory = new MenuCategory(); item = computed(() => this.itemResource.value() ?? new MenuCategory());
formModel = linkedSignal({
constructor() { source: this.item,
// Create form computation: (itemVal): MenuCategoryDetailFormData => {
this.form = new FormGroup({ return {
name: new FormControl<string>('', { nonNullable: true }), name: itemVal.name,
isActive: new FormControl<boolean>(true, { nonNullable: true }), isActive: itemVal.isActive,
};
},
}); });
}
ngOnInit() { form = form(this.formModel);
this.route.data.subscribe((value) => {
const data = value as { item: MenuCategory };
this.showItem(data.item);
});
}
showItem(item: MenuCategory) {
this.item = item;
this.form.setValue({
name: this.item.name,
isActive: this.item.isActive,
});
}
ngAfterViewInit() { ngAfterViewInit() {
setTimeout(() => { setTimeout(() => {
@@ -70,20 +61,20 @@ export class MenuCategoryDetailComponent implements OnInit, AfterViewInit {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/menu-categories'); this.router.navigateByUrl('/menu-categories');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
delete() { delete() {
this.ser.delete(this.item.id as string).subscribe({ this.ser.delete(this.item().id as string).subscribe({
next: () => { next: () => {
this.snackBar.open('', 'Success'); this.snackBar.open('', 'Success');
this.router.navigateByUrl('/menu-categories'); this.router.navigateByUrl('/menu-categories');
}, },
error: (error) => { error: (error: unknown) => {
this.snackBar.open(error, 'Error'); this.snackBar.open(error as string, 'Error');
}, },
}); });
} }
@@ -102,9 +93,11 @@ export class MenuCategoryDetailComponent implements OnInit, AfterViewInit {
} }
getItem(): MenuCategory { getItem(): MenuCategory {
const formModel = this.form.value; const formModel = this.formModel();
this.item.name = formModel.name ?? ''; return new MenuCategory({
this.item.isActive = formModel.isActive ?? true; ...this.item(),
return this.item; name: formModel.name ?? '',
isActive: formModel.isActive ?? true,
});
} }
} }
@@ -1,15 +0,0 @@
import { inject, TestBed } from '@angular/core/testing';
import { MenuCategoryListResolver } from './menu-category-list-resolver.service';
describe('MenuCategoryListResolver', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [MenuCategoryListResolver],
});
});
it('should be created', inject([MenuCategoryListResolver], (service: MenuCategoryListResolver) => {
expect(service).toBeTruthy();
}));
});
@@ -1,9 +0,0 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { MenuCategory } from '../core/menu-category';
import { MenuCategoryService } from './menu-category.service';
export const menuCategoryListResolver: ResolveFn<MenuCategory[]> = () => {
return inject(MenuCategoryService).list(false);
};
@@ -1,24 +0,0 @@
import { DataSource } from '@angular/cdk/collections';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { MenuCategory } from '../../core/menu-category';
export class MenuCategoryListDatasource extends DataSource<MenuCategory> {
private data: MenuCategory[] = [];
constructor(private readonly dataObs: Observable<MenuCategory[]>) {
super();
this.dataObs = dataObs.pipe(
tap((x) => {
this.data = x;
}),
);
}
connect(): Observable<MenuCategory[]> {
return this.dataObs;
}
disconnect() {}
}
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more