In progress Sales import and Recipe import.
First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
1 parent
75ccbdc6f9
commit
b425b299bf
50 files changed
+5306
-123
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 }[];
|
||||
}
|
||||
Reference in new issue
Block a user