In progress Sales import and Recipe import.

First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
tanshu committed 2026-09-11 10:43:23 +00:00
1 parent 75ccbdc6f9
commit b425b299bf
50 files changed
+5306 -123

No files matched your search

+4
View File
@@ -149,6 +149,10 @@ export const routes: Routes = [
path: 'purchase-return',
loadChildren: () => import('./purchase-return/purchase-return.routes').then((mod) => mod.routes),
},
{
path: 'sales-import',
loadChildren: () => import('./sales-import/sales-import.routes').then((mod) => mod.routes),
},
{
path: 'settings',
loadChildren: () => import('./settings/settings.routes').then((mod) => mod.routes),
@@ -10,6 +10,7 @@
<a mat-menu-item routerLink="/receipt">Receipt - F6</a>
<a mat-menu-item routerLink="/issue">Issue</a>
<a mat-menu-item routerLink="/transaction-import">Transaction Import</a>
<a mat-menu-item routerLink="/sales-import">Sales Import</a>
</mat-menu>
<button mat-button [matMenuTriggerFor]="voucherMenu">Voucher Entry</button>
@@ -0,0 +1,145 @@
.dialog-content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
max-height: 65vh;
overflow-y: auto;
}
.flex-col {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-row {
display: flex;
align-items: center;
gap: 8px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.action-counts {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0;
padding: 0;
list-style: none;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge-unchanged,
.badge-unchanged-status {
background-color: #e8f5e9;
color: #1b5e20;
}
.badge-new,
.badge-created {
background-color: #e3f2fd;
color: #0d47a1;
}
.badge-overwrite,
.badge-updated {
background-color: #fff3e0;
color: #e65100;
}
.badge-skip,
.badge-skipped {
background-color: #ffebee;
color: #b71c1c;
}
.badge-unmatched {
background-color: #ede7f6;
color: #4527a0;
}
.badge-invalid {
background-color: #fce4ec;
color: #880e4f;
}
.badge-master {
background-color: #e0f2f1;
color: #004d40;
}
.warnings {
padding: 8px 12px;
border: 1px solid #ffcc80;
border-radius: 4px;
background-color: #fff8e1;
font-size: 13px;
}
.unmatched-box {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px 12px;
margin: 0;
}
.hint {
margin: 4px 0 8px;
font-size: 13px;
color: #616161;
}
.unmatched-list {
display: flex;
flex-direction: column;
max-height: 180px;
overflow-y: auto;
margin-top: 8px;
}
.muted {
color: #757575;
font-size: 12px;
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
}
.dish-table {
max-height: 260px;
overflow-y: auto;
width: 100%;
}
.result-list {
margin: 0;
padding-left: 20px;
}
.error {
margin: 0;
color: #b71c1c;
}
.spacer {
flex: 1 1 auto;
}
@@ -0,0 +1,200 @@
<h2 mat-dialog-title>Import Recipes from Excel</h2>
<div mat-dialog-content class="dialog-content" cdkScrollable>
@if (step() === 'select') {
<div class="flex-col">
<p>
Upload the recipe workbook. The file is analysed first and you can review every planned change before anything
is saved.
</p>
<div class="file-row">
<button type="button" mat-stroked-button (click)="fileInput.click()">
<mat-icon>upload_file</mat-icon>
{{ file() ? file()!.name : 'Choose .xlsx file' }}
</button>
<input
#fileInput
type="file"
accept=".xlsx"
class="visually-hidden"
(change)="onFileChange($event)"
aria-label="Recipe workbook file"
/>
</div>
<mat-form-field appearance="outline">
<mat-label>Import date</mat-label>
<input
matInput
type="date"
[ngModel]="date()"
(ngModelChange)="date.set($event)"
required
aria-required="true"
/>
<mat-hint>Recipes are imported with this date. Existing recipes for the same date are overwritten.</mat-hint>
</mat-form-field>
</div>
}
@if (step() === 'review' && preview()) {
<div class="flex-col">
<h3>Summary for {{ date() }}</h3>
<ul class="action-counts" aria-label="Planned actions">
@for (c of actionCounts(); track c.action) {
<li>
<span [class]="'badge ' + actionClass(c.action)">{{ c.action }}</span> {{ c.count }} dishes
</li>
}
<li><span class="badge badge-master">master data</span> {{ masterChanges().length }} changes</li>
</ul>
@if (warnings().length > 0) {
<div class="warnings" role="note">
<strong>Warnings</strong>
<ul>
@for (w of warnings(); track w) {
<li>{{ w }}</li>
}
</ul>
</div>
}
<fieldset class="unmatched-box">
<legend>Create missing items ({{ unmatched().length }})</legend>
<p class="hint">
Checked items will be created. Unchecked items are not created and dishes that need them are skipped.
</p>
@if (unmatched().length > 0) {
<mat-checkbox [checked]="allChecked()" (change)="toggleAll($event.checked)">Select all</mat-checkbox>
}
<div class="unmatched-list" role="group" aria-label="Items to create">
@for (u of unmatched(); track u.name) {
<mat-checkbox [checked]="isChecked(u.name)" (change)="toggleOne(u.name, $event.checked)">
{{ u.name }}
<span class="muted">({{ u.kind }}{{ u.suggestedGroup ? ' → ' + u.suggestedGroup : '' }})</span>
</mat-checkbox>
}
</div>
</fieldset>
<div class="options" role="group" aria-label="Import options">
<mat-checkbox [ngModel]="salePrices()" (ngModelChange)="salePrices.set($event)"
>Update sale prices from the menu summary</mat-checkbox
>
<mat-checkbox [ngModel]="masterData()" (ngModelChange)="masterData.set($event)"
>Update fraction and yield %</mat-checkbox
>
<mat-checkbox [ngModel]="tags()" (ngModelChange)="tags.set($event)">Apply menu sections as tags</mat-checkbox>
</div>
<details>
<summary>Dish details ({{ dishes().length }})</summary>
<table mat-table [dataSource]="dishes()" class="dish-table">
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Action</th>
<td mat-cell *matCellDef="let row">
<span [class]="'badge ' + actionClass(row.action)">{{ row.action }}</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef scope="col">Name</th>
<td mat-cell *matCellDef="let row">{{ row.name }}</td>
</ng-container>
<ng-container matColumnDef="sheet">
<th mat-header-cell *matHeaderCellDef scope="col">Sheet</th>
<td mat-cell *matCellDef="let row">{{ row.sheet }}</td>
</ng-container>
<ng-container matColumnDef="yield">
<th mat-header-cell *matHeaderCellDef scope="col">Yield</th>
<td mat-cell *matCellDef="let row">{{ row.recipeYield | number: '1.0-4' }}</td>
</ng-container>
<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef scope="col">Price</th>
<td mat-cell *matCellDef="let row">
{{ row.salePrice === null ? '—' : (row.salePrice | number: '1.2-2') }}
</td>
</ng-container>
<ng-container matColumnDef="message">
<th mat-header-cell *matHeaderCellDef scope="col">Message</th>
<td mat-cell *matCellDef="let row">{{ row.message ?? '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</details>
</div>
}
@if (step() === 'result' && result()) {
<div class="flex-col">
<h3>Import complete</h3>
<ul class="result-list">
<li>{{ result()!.dishes.length }} dishes processed</li>
<li>{{ result()!.createdProducts.length }} items created</li>
<li>{{ result()!.masterChanges }} master-data changes applied</li>
</ul>
@if (result()!.createdProducts.length > 0) {
<p>Created: {{ result()!.createdProducts.join(', ') }}</p>
}
@if (result()!.warnings.length > 0) {
<div class="warnings" role="note">
<strong>Warnings</strong>
<ul>
@for (w of result()!.warnings; track w) {
<li>{{ w }}</li>
}
</ul>
</div>
}
@if (result()!.dishes.length > 0) {
<details>
<summary>Per-dish report ({{ result()!.dishes.length }})</summary>
<table mat-table [dataSource]="result()!.dishes" class="dish-table">
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Status</th>
<td mat-cell *matCellDef="let row">
<span [class]="'badge badge-' + row.status">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef scope="col">Name</th>
<td mat-cell *matCellDef="let row">{{ row.name }}</td>
</ng-container>
<ng-container matColumnDef="sheet">
<th mat-header-cell *matHeaderCellDef scope="col">Message</th>
<td mat-cell *matCellDef="let row">{{ row.message ?? '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</details>
}
</div>
}
@if (errorMessage()) {
<p class="error" role="alert">{{ errorMessage() }}</p>
}
</div>
<div mat-dialog-actions>
@if (busy()) {
<mat-spinner diameter="24" aria-label="Working"></mat-spinner>
}
<span class="spacer"></span>
@if (step() === 'select') {
<button mat-button (click)="close()">Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canAnalyze() || busy()" (click)="analyze()">
Analyse file
</button>
}
@if (step() === 'review') {
<button mat-button [disabled]="busy()" (click)="step.set('select')">Back</button>
<button mat-raised-button color="primary" [disabled]="busy()" (click)="execute()">
Import {{ selectedCreate().size > 0 ? 'and create ' + selectedCreate().size + ' items' : '' }}
</button>
}
@if (step() === 'result') {
<button mat-raised-button color="primary" (click)="close()">Done</button>
}
</div>
@@ -0,0 +1,178 @@
import { DecimalPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import { RecipeImportPreview, RecipeImportResult } from '../recipe-import';
import { RecipeService } from '../recipe.service';
interface ActionCount {
action: string;
count: number;
}
@Component({
selector: 'app-recipe-import-dialog',
templateUrl: './recipe-import-dialog.component.html',
styleUrls: ['./recipe-import-dialog.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatProgressSpinnerModule,
MatTableModule,
FormsModule,
DecimalPipe,
],
})
export class RecipeImportDialogComponent {
private readonly ser = inject(RecipeService);
readonly dialogRef = inject<MatDialogRef<RecipeImportDialogComponent>>(MatDialogRef);
readonly step = signal<'select' | 'review' | 'result'>('select');
readonly busy = signal(false);
readonly errorMessage = signal('');
readonly file = signal<File | null>(null);
readonly date = signal('');
readonly preview = signal<RecipeImportPreview | null>(null);
readonly result = signal<RecipeImportResult | null>(null);
readonly selectedCreate = signal<Set<string>>(new Set());
readonly salePrices = signal(true);
readonly masterData = signal(true);
readonly tags = signal(true);
readonly unmatched = computed(() => this.preview()?.unmatched ?? []);
readonly dishes = computed(() => this.preview()?.dishes ?? []);
readonly masterChanges = computed(() => this.preview()?.masterChanges ?? []);
readonly warnings = computed(() => this.preview()?.warnings ?? []);
readonly actionCounts = computed<ActionCount[]>(() => {
const counts = new Map<string, number>();
for (const d of this.dishes()) {
counts.set(d.action, (counts.get(d.action) ?? 0) + 1);
}
return [...counts.entries()].map(([action, count]) => ({ action, count }));
});
readonly displayedColumns = ['action', 'name', 'sheet', 'yield', 'price', 'message'];
readonly canAnalyze = computed(() => this.file() !== null && this.date() !== '');
readonly allChecked = computed(() => {
const selected = this.selectedCreate();
return this.unmatched().length > 0 && this.unmatched().every((u) => selected.has(u.name));
});
onFileChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.file.set(input.files?.[0] ?? null);
}
toggleAll(event: unknown): void {
const checked = event as boolean;
if (checked) {
this.selectedCreate.set(new Set(this.unmatched().map((u) => u.name)));
} else {
this.selectedCreate.set(new Set());
}
}
toggleOne(name: string, checked: boolean): void {
this.selectedCreate.update((set) => {
const next = new Set(set);
if (checked) {
next.add(name);
} else {
next.delete(name);
}
return next;
});
}
isChecked(name: string): boolean {
return this.selectedCreate().has(name);
}
analyze(): void {
const file = this.file();
if (!file || !this.date()) {
return;
}
this.busy.set(true);
this.errorMessage.set('');
this.ser.importPreview(file, this.date()).subscribe({
next: (preview) => {
this.preview.set(preview);
this.selectedCreate.set(new Set(preview.unmatched.map((u) => u.name)));
this.busy.set(false);
this.step.set('review');
},
error: (err: unknown) => {
this.busy.set(false);
this.errorMessage.set(this.extractError(err, 'Could not analyse the file.'));
},
});
}
execute(): void {
const file = this.file();
if (!file || !this.date()) {
return;
}
this.busy.set(true);
this.errorMessage.set('');
this.ser
.importExecute(file, this.date(), {
create: [...this.selectedCreate()],
salePrices: this.salePrices(),
masterData: this.masterData(),
tags: this.tags(),
})
.subscribe({
next: (result) => {
this.result.set(result);
this.busy.set(false);
this.step.set('result');
},
error: (err: unknown) => {
this.busy.set(false);
this.errorMessage.set(this.extractError(err, 'Import failed. Please try again.'));
},
});
}
close(): void {
this.dialogRef.close(this.result() !== null);
}
actionClass(action: string): string {
return `action-${action}`;
}
private extractError(err: unknown, fallback: string): string {
if (err !== null && typeof err === 'object' && 'error' in err) {
const detail = (err as { error?: { detail?: unknown } }).error?.detail;
if (typeof detail === 'string' && detail !== '') {
return detail;
}
}
if (err instanceof Error && err.message !== '') {
return err.message;
}
return fallback;
}
}
+61
View File
@@ -0,0 +1,61 @@
export interface ImportIngredientPlan {
name: string;
productId: string | null;
quantity: number;
unit: string | null;
unitMismatch: boolean;
}
export interface ImportDishPlan {
name: string;
sheet: string;
productId: string | null;
skuId: string | null;
action: 'unchanged' | 'overwrite' | 'new' | 'skip' | 'invalid' | 'unmatched';
recipeYield: number;
existingDate: string | null;
sectionTag: string | null;
salePrice: number | null;
message: string | null;
ingredients: ImportIngredientPlan[];
}
export interface ImportMasterPlan {
productId: string;
skuId: string;
name: string;
field: 'sale_price' | 'fraction' | 'product_yield';
old: number | null;
new: number;
}
export interface ImportUnmatchedName {
name: string;
kind: 'dish' | 'ingredient';
sheet: string;
section: string | null;
suggestedGroup: string | null;
unitHint: string | null;
}
export interface RecipeImportPreview {
date: string;
dishes: ImportDishPlan[];
masterChanges: ImportMasterPlan[];
unmatched: ImportUnmatchedName[];
warnings: string[];
}
export interface ImportDishResult {
name: string;
status: 'created' | 'updated' | 'unchanged' | 'skipped';
message: string | null;
}
export interface RecipeImportResult {
date: string;
createdProducts: string[];
dishes: ImportDishResult[];
masterChanges: number;
warnings: string[];
}
@@ -1,15 +1,20 @@
<h2 class="row-container space-between">
<span>Recipes</span>
<a mat-icon-button [href]="'/api/recipes/xlsx?p=' + period.id">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-icon-button href="/api/recipes/nutrition">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
<span class="row-container">
<button type="button" mat-icon-button (click)="openImport()" aria-label="Import recipes from Excel">
<mat-icon>upload</mat-icon>
</button>
<a mat-icon-button [href]="'/api/recipes/xlsx?p=' + period.id" aria-label="Export recipes to Excel">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-icon-button href="/api/recipes/nutrition" aria-label="Export nutrition report">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
</span>
</h2>
<form class="flex-col" [formRoot]="form">
@@ -3,6 +3,7 @@ import { form as createForm, FormField, FormRoot } from '@angular/forms/signals'
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
@@ -16,6 +17,7 @@ import { PeriodService } from '../../period/period.service';
import { ProductGroupService } from '../../product-group/product-group.service';
import { ErrorStateComponent } from '../../shared/error-state/error-state.component';
import { SkeletonLoaderComponent } from '../../shared/skeleton-loader/skeleton-loader.component';
import { RecipeImportDialogComponent } from '../recipe-import-dialog/recipe-import-dialog.component';
import { RecipeService } from '../recipe.service';
export interface RecipeListFormData {
@@ -49,6 +51,7 @@ export class RecipeListComponent {
private router = inject(Router);
private periodSer = inject(PeriodService);
private productGroupSer = inject(ProductGroupService);
private dialog = inject(MatDialog);
pageSize = signal(50);
pageIndex = signal(0);
@@ -140,6 +143,20 @@ export class RecipeListComponent {
this.sortActive.set(sort.active);
this.sortDirection.set(sort.direction);
}
openImport(): void {
this.dialog
.open(RecipeImportDialogComponent, {
width: '720px',
maxWidth: '95vw',
})
.afterClosed()
.subscribe((imported: boolean) => {
if (imported) {
this.resource.reload();
}
});
}
}
const compare = (a: string | number | boolean, b: string | number | boolean, isAsc: boolean) =>
(a < b ? -1 : 1) * (isAsc ? 1 : -1);
+31
View File
@@ -5,8 +5,17 @@ import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { Recipe } from './recipe';
import { RecipeImportPreview, RecipeImportResult } from './recipe-import';
const url = '/api/recipes';
export interface RecipeImportExecuteOptions {
create: string[];
salePrices: boolean;
masterData: boolean;
tags: boolean;
}
@Injectable({ providedIn: 'root' })
export class RecipeService {
private http = inject(HttpClient);
@@ -51,4 +60,26 @@ export class RecipeService {
.delete<Recipe>(`${url}/${id}`)
.pipe(catchError(this.log.handleError('RecipeService', 'delete'))) as Observable<Recipe>;
}
importPreview(file: File, date: string): Observable<RecipeImportPreview> {
const data = new FormData();
data.set('file', file);
data.set('date_', date);
return this.http
.post<RecipeImportPreview>(`${url}/import/preview`, data)
.pipe(catchError(this.log.handleError('RecipeService', 'importPreview'))) as Observable<RecipeImportPreview>;
}
importExecute(file: File, date: string, options: RecipeImportExecuteOptions): Observable<RecipeImportResult> {
const data = new FormData();
data.set('file', file);
data.set('date_', date);
data.set('create', JSON.stringify(options.create));
data.set('sale_prices', String(options.salePrices));
data.set('master_data', String(options.masterData));
data.set('tags', String(options.tags));
return this.http
.post<RecipeImportResult>(`${url}/import/execute`, data)
.pipe(catchError(this.log.handleError('RecipeService', 'importExecute'))) as Observable<RecipeImportResult>;
}
}
@@ -0,0 +1,18 @@
.row-container {
display: flex;
gap: 12px;
align-items: center;
}
.space-between {
justify-content: space-between;
}
.full-width {
width: 100%;
margin-bottom: 16px;
}
.number {
text-align: right;
}
@@ -0,0 +1,85 @@
<header class="row-container space-between">
<h2>Sale Voucher</h2>
<a mat-icon-button routerLink="/sales-import" aria-label="Back to sales import">
<mat-icon>arrow_back</mat-icon>
</a>
</header>
@if (loading()) {
<app-skeleton-loader />
}
@if (error(); as err) {
<app-error-state [message]="err.message" />
}
@if (voucher(); as voucher) {
<section aria-label="Voucher details">
<p>
<strong>{{ voucher.date | date: 'dd-MMM-yyyy' }}</strong>
— {{ voucher.narration }}
</p>
<p>
Sold from <strong>{{ name(voucher.source) }}</strong> to
<strong>{{ name(voucher.destination) }}</strong>
</p>
</section>
<table mat-table [dataSource]="voucher.inventories" class="full-width">
<ng-container matColumnDef="product">
<th mat-header-cell *matHeaderCellDef scope="col">Product</th>
<td mat-cell *matCellDef="let line">{{ line.batch.sku.name }}</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef scope="col">Quantity</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.quantity | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="rate">
<th mat-header-cell *matHeaderCellDef scope="col">Rate</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.rate | currency: 'INR' : 'symbol' : '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="tax">
<th mat-header-cell *matHeaderCellDef scope="col">Tax</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.tax | percent: '1.0-2' }}</td>
</ng-container>
<ng-container matColumnDef="discount">
<th mat-header-cell *matHeaderCellDef scope="col">Discount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.discount | percent: '1.0-2' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.amount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="happyHour">
<th mat-header-cell *matHeaderCellDef scope="col">Happy Hour</th>
<td mat-cell *matCellDef="let line">{{ line.isHappyHour ? 'Yes' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lineColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lineColumns"></tr>
</table>
<table mat-table [dataSource]="voucher.journals" class="full-width">
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef scope="col">Account</th>
<td mat-cell *matCellDef="let journal">{{ journal.account.name }}</td>
</ng-container>
<ng-container matColumnDef="costCentre">
<th mat-header-cell *matHeaderCellDef scope="col">Cost Centre</th>
<td mat-cell *matCellDef="let journal">{{ name(journal.costCentre) }}</td>
</ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef scope="col">Debit</th>
<td mat-cell *matCellDef="let journal" class="number">
{{ journal.debit === 1 ? (journal.amount | number: '1.2-2') : '' }}
</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef scope="col">Credit</th>
<td mat-cell *matCellDef="let journal" class="number">
{{ journal.debit === -1 ? (journal.amount | number: '1.2-2') : '' }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
}
@@ -0,0 +1,63 @@
import { CurrencyPipe, DatePipe, DecimalPipe, PercentPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { CostCentre } from '../core/cost-centre';
import { CostCentreService } from '../cost-centre/cost-centre.service';
import { ErrorStateComponent } from '../shared/error-state/error-state.component';
import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component';
import { SalesImportService } from './sales-import.service';
@Component({
selector: 'app-sale-voucher',
templateUrl: './sale-voucher.component.html',
styleUrls: ['./sale-voucher.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DatePipe,
DecimalPipe,
CurrencyPipe,
PercentPipe,
MatButtonModule,
MatIconModule,
MatTableModule,
RouterLink,
SkeletonLoaderComponent,
ErrorStateComponent,
],
})
export class SaleVoucherComponent {
private ser = inject(SalesImportService);
private costCentreSer = inject(CostCentreService);
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
voucherResource = this.ser.voucher(this.id);
voucher = computed(() => this.voucherResource.value() ?? null);
loading = computed(() => this.voucherResource.isLoading());
error = computed(() => this.voucherResource.error());
costCentreResource = this.costCentreSer.list();
costCentres = computed(() => this.costCentreResource.value() ?? []);
displayedColumns = ['account', 'costCentre', 'debit', 'credit'];
lineColumns = ['product', 'quantity', 'rate', 'tax', 'discount', 'amount', 'happyHour'];
name(costCentre: CostCentre | null | undefined): string {
if (!costCentre) {
return '';
}
return this.costCentres().find((c) => c.id === costCentre.id)?.name ?? costCentre.name ?? '';
}
trackLine(index: number): string {
return `line-${index}`;
}
trackJournal(index: number): string {
return `journal-${index}`;
}
}
@@ -0,0 +1,78 @@
.row-container {
display: flex;
gap: 12px;
align-items: center;
}
.wrap {
flex-wrap: wrap;
}
.flex-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.flex-auto {
flex: 1 1 auto;
}
.full-width {
width: 100%;
}
.number {
text-align: right;
}
.mapping-field {
min-width: 220px;
}
.notice {
border: 1px solid #b26a00;
border-radius: 4px;
padding: 8px 12px;
margin: 8px 0;
}
.action {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.85em;
}
.action-create {
background-color: #1b5e20;
color: #ffffff;
}
.action-skip {
background-color: #616161;
color: #ffffff;
}
.action-replace {
background-color: #e65100;
color: #ffffff;
}
.action-mixed {
background-color: #4a148c;
color: #ffffff;
}
.selected {
background-color: rgba(0, 0, 0, 0.04);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@@ -0,0 +1,163 @@
<h2>Sales Import</h2>
<form class="flex-col" [formRoot]="form" (submit)="$event.preventDefault(); show()">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Start Date</mat-label>
<input matInput [matDatepicker]="startDate" [formField]="form.startDate" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
<mat-datepicker #startDate></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Finish Date</mat-label>
<input matInput [matDatepicker]="finishDate" [formField]="form.finishDate" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
<mat-datepicker #finishDate></mat-datepicker>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="loading()">Preview</button>
</div>
</form>
@if (unmappedCategories() > 0 || newProducts().length > 0) {
<section class="notice" aria-label="Import prerequisites">
@if (unmappedCategories() > 0) {
<p>
{{ unmappedCategories() }} sale category(ies) are not mapped to cost centres. Map them below and save before
importing.
</p>
}
@if (newProducts().length > 0) {
<p>{{ newProducts().length }} new product(s) will be created automatically on import.</p>
}
</section>
}
@if (loading()) {
<app-skeleton-loader />
}
@if (error(); as err) {
<app-error-state [message]="err.message" />
}
@if (preview()) {
<section aria-label="Sale category mapping">
<h3>Sale Category Mapping</h3>
<div class="row-container wrap">
@for (entry of mappingEntries(); track entry.saleCategoryId) {
<mat-form-field class="mapping-field">
<mat-label>{{ entry.saleCategoryName ?? entry.saleCategoryId }}</mat-label>
<mat-select
[value]="mappingSelection()[entry.saleCategoryId] ?? null"
(selectionChange)="onMappingChange(entry.saleCategoryId, $event.value)"
>
<mat-option [value]="null">Not mapped</mat-option>
@for (costCentre of costCentres(); track costCentre.id) {
<mat-option [value]="costCentre.id">{{ costCentre.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<button mat-stroked-button type="button" (click)="saveMapping()">Save Mapping</button>
</div>
</section>
<table mat-table [dataSource]="days()" class="full-width">
<ng-container matColumnDef="expand">
<th mat-header-cell *matHeaderCellDef scope="col"><span class="visually-hidden">Expand</span></th>
<td mat-cell *matCellDef="let day">
<button
mat-icon-button
type="button"
[attr.aria-label]="'Show vouchers for ' + day.businessDate"
[attr.aria-expanded]="expanded() === day"
(click)="toggleExpanded(day)"
>
<mat-icon>{{ expanded() === day ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</td>
</ng-container>
<ng-container matColumnDef="businessDate">
<th mat-header-cell *matHeaderCellDef scope="col">Business Date</th>
<td mat-cell *matCellDef="let day">{{ day.businessDate | date: 'dd-MMM-yyyy' }}</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Status</th>
<td mat-cell *matCellDef="let day">
<span [class]="actionClass(day.action)">{{ day.action }}</span>
</td>
</ng-container>
<ng-container matColumnDef="voucherCount">
<th mat-header-cell *matHeaderCellDef scope="col">Vouchers</th>
<td mat-cell *matCellDef="let day">{{ day.vouchers.length }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let day" class="number">{{ day.amount | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [class.selected]="expanded() === row"></tr>
</table>
@if (expanded(); as day) {
<section aria-label="Vouchers for selected day">
@for (voucher of day.vouchers; track trackVoucher($index, voucher)) {
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>{{ voucher.saleCategoryName }} → {{ voucher.costCentre.name }}</mat-panel-title>
<mat-panel-description>
<span [class]="actionClass(voucher.action)">{{ voucher.action }}</span>
<span class="number">{{ voucher.amount | number: '1.2-2' }}</span>
</mat-panel-description>
</mat-expansion-panel-header>
<p>{{ voucher.narration }}</p>
<table mat-table [dataSource]="voucher.lines" class="full-width">
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef scope="col">Product</th>
<td mat-cell *matCellDef="let line">{{ line.name }} ({{ line.units }})</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef scope="col">Quantity</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.quantity }}</td>
</ng-container>
<ng-container matColumnDef="rate">
<th mat-header-cell *matHeaderCellDef scope="col">Rate</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.rate | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="taxRate">
<th mat-header-cell *matHeaderCellDef scope="col">Tax</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.taxRate }}</td>
</ng-container>
<ng-container matColumnDef="discount">
<th mat-header-cell *matHeaderCellDef scope="col">Discount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.discount }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.amount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="happyHour">
<th mat-header-cell *matHeaderCellDef scope="col">Happy Hour</th>
<td mat-cell *matCellDef="let line">{{ line.isHappyHour ? 'Yes' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lineColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lineColumns"></tr>
</table>
</mat-expansion-panel>
}
</section>
}
<div class="row-container">
<button
mat-flat-button
type="button"
color="primary"
[disabled]="importing() || !canImport()"
[matTooltip]="canImport() ? '' : 'Map all sale categories to cost centres first'"
(click)="import()"
>
{{ importing() ? 'Importing…' : 'Import' }}
</button>
</div>
}
@@ -0,0 +1,169 @@
import { DatePipe, DecimalPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { form as createForm, FormField, FormRoot } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import moment from 'moment';
import { ErrorStateComponent } from '../shared/error-state/error-state.component';
import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component';
import { DayPlan, ImportRequest, MappingEntry, PreviewResponse, SaleLine, VoucherPlan } from './sales-import';
import { SalesImportService } from './sales-import.service';
interface SalesImportFormData {
startDate: Date;
finishDate: Date;
}
@Component({
selector: 'app-sales-import',
templateUrl: './sales-import.component.html',
styleUrls: ['./sales-import.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormField,
FormRoot,
MatFormFieldModule,
MatInputModule,
MatDatepickerModule,
MatOptionModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatExpansionModule,
MatTableModule,
MatTooltipModule,
MatSnackBarModule,
DatePipe,
DecimalPipe,
SkeletonLoaderComponent,
ErrorStateComponent,
],
})
export class SalesImportComponent {
private ser = inject(SalesImportService);
private snackBar = inject(MatSnackBar);
model = signal<SalesImportFormData>({
startDate: moment().startOf('month').toDate(),
finishDate: moment().toDate(),
});
form = createForm(this.model);
previewRequest = signal<ImportRequest | null>(null);
previewResource = httpResource<PreviewResponse>(() => {
const request = this.previewRequest();
return request === null ? undefined : { url: '/api/sales-import/preview', method: 'POST', body: request };
});
error = computed(() => this.previewResource.error());
loading = computed(() => this.previewResource.isLoading());
preview = computed(() => this.previewResource.value() ?? null);
days = computed(() => this.preview()?.days ?? []);
costCentres = computed(() => this.preview()?.costCentres ?? []);
mappingEntries = computed(() => this.preview()?.mapping ?? []);
newProducts = computed(() => this.preview()?.newProducts ?? []);
unmappedCategories = computed(
() => this.mappingEntries().filter((entry) => this.mappingSelection()[entry.saleCategoryId] == null).length,
);
canImport = computed(() => this.days().length > 0 && this.unmappedCategories() === 0);
mappingSelection = signal<Record<string, string | null>>({});
importing = signal(false);
expanded = signal<DayPlan | null>(null);
displayedColumns = ['expand', 'businessDate', 'action', 'voucherCount', 'amount'];
lineColumns = ['productName', 'quantity', 'rate', 'taxRate', 'discount', 'amount', 'happyHour'];
constructor() {
effect(() => {
const response = this.preview();
if (response) {
this.mappingSelection.set(
Object.fromEntries(response.mapping.map((entry) => [entry.saleCategoryId, entry.costCentre?.id ?? null])),
);
}
});
}
show(): void {
const model = this.model();
this.previewRequest.set({
startDate: moment(model.startDate).format('YYYY-MM-DD'),
finishDate: moment(model.finishDate).format('YYYY-MM-DD'),
});
}
onMappingChange(saleCategoryId: string, costCentreId: string | null): void {
this.mappingSelection.update((current) => ({ ...current, [saleCategoryId]: costCentreId }));
}
saveMapping(): void {
const entries: MappingEntry[] = this.mappingEntries();
const selection = this.mappingSelection();
const mapping = entries.map((entry) => ({
saleCategoryId: entry.saleCategoryId,
costCentreId: selection[entry.saleCategoryId] ?? null,
}));
this.ser.saveMapping({ mapping }).subscribe({
next: () => {
this.snackBar.open('Sale category mapping saved', 'Close', { duration: 3000 });
this.show();
},
error: (err) => this.snackBar.open('Error saving mapping: ' + err.message, 'Close', { duration: 5000 }),
});
}
import(): void {
const model = this.model();
const request: ImportRequest = {
startDate: moment(model.startDate).format('YYYY-MM-DD'),
finishDate: moment(model.finishDate).format('YYYY-MM-DD'),
};
this.importing.set(true);
this.ser.execute(request).subscribe({
next: (result) => {
this.importing.set(false);
this.snackBar.open(result.message, 'Close', { duration: 5000 });
this.show();
},
error: (err) => {
this.importing.set(false);
this.snackBar.open('Import failed: ' + (err.error?.detail ?? err.message), 'Close', { duration: 8000 });
},
});
}
toggleExpanded(day: DayPlan): void {
this.expanded.update((current) => (current === day ? null : day));
}
actionClass(action: string): string {
return `action action-${action}`;
}
trackDay(_index: number, day: DayPlan): string {
return day.businessDate;
}
trackVoucher(_index: number, voucher: VoucherPlan): string {
return `${voucher.saleCategoryId}-${voucher.costCentre.id}`;
}
trackLine(_index: number, line: SaleLine): string {
return `${line.skuId ?? line.productName}-${line.rate}-${line.isHappyHour}`;
}
}
@@ -0,0 +1,24 @@
import { Routes } from '@angular/router';
import { authGuard } from '../auth/auth-guard.service';
import { SaleVoucherComponent } from './sale-voucher.component';
import { SalesImportComponent } from './sales-import.component';
export const routes: Routes = [
{
path: '',
component: SalesImportComponent,
canActivate: [authGuard],
data: {
permission: 'Sales Import',
},
},
{
path: 'voucher/:id',
component: SaleVoucherComponent,
canActivate: [authGuard],
data: {
permission: 'Sales Import',
},
},
];
@@ -0,0 +1,43 @@
import { HttpClient, httpResource } from '@angular/common/http';
import { inject, Injectable, Signal } from '@angular/core';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorLoggerService } from '../core/error-logger.service';
import { Voucher } from '../core/voucher';
import { ExecuteResponse, ImportRequest, MappingUpdate, MappingEntry, PreviewResponse } from './sales-import';
const url = '/api/sales-import';
@Injectable({
providedIn: 'root',
})
export class SalesImportService {
private http = inject(HttpClient);
private log = inject(ErrorLoggerService);
voucher(id: Signal<string | null>) {
return httpResource<Voucher>(() => {
const id_ = id();
return id_ === null ? undefined : `${url}/voucher/${id_}`;
});
}
preview(request: ImportRequest): Observable<PreviewResponse> {
return this.http
.post<PreviewResponse>(`${url}/preview`, request)
.pipe(catchError(this.log.handleError('SalesImportService', 'preview'))) as Observable<PreviewResponse>;
}
execute(request: ImportRequest): Observable<ExecuteResponse> {
return this.http
.post<ExecuteResponse>(`${url}/execute`, request)
.pipe(catchError(this.log.handleError('SalesImportService', 'execute'))) as Observable<ExecuteResponse>;
}
saveMapping(mapping: MappingUpdate): Observable<MappingEntry[]> {
return this.http
.put<MappingEntry[]>(`${url}/mapping`, mapping)
.pipe(catchError(this.log.handleError('SalesImportService', 'saveMapping'))) as Observable<MappingEntry[]>;
}
}
@@ -0,0 +1,61 @@
import { CostCentre } from '../core/cost-centre';
export interface SaleLine {
productId: string | null;
skuId: string | null;
name: string;
units: string;
saleCategoryId: string;
saleCategoryName: string;
quantity: number;
rate: number;
taxRate: number;
discount: number;
isHappyHour: boolean;
amount: number;
}
export interface VoucherPlan {
saleCategoryId: string;
saleCategoryName: string;
costCentre: CostCentre;
narration: string;
lines: SaleLine[];
amount: number;
action: 'create' | 'skip' | 'replace';
existingAmount: number | null;
}
export interface DayPlan {
businessDate: string;
action: 'create' | 'skip' | 'replace' | 'mixed';
vouchers: VoucherPlan[];
amount: number;
}
export interface MappingEntry {
saleCategoryId: string;
saleCategoryName: string | null;
costCentre: CostCentre | null;
}
export interface PreviewResponse {
days: DayPlan[];
mapping: MappingEntry[];
costCentres: CostCentre[];
newProducts: string[];
}
export interface ImportRequest {
startDate: string;
finishDate: string;
}
export interface ExecuteResponse {
message: string;
days: DayPlan[];
}
export interface MappingUpdate {
mapping: { saleCategoryId: string; costCentreId: string | null }[];
}