Files
brewman/overlord/src/app/issue/issue.component.ts
T

443 lines
14 KiB
TypeScript

import { CurrencyPipe, DecimalPipe } from '@angular/common';
import { afterNextRender, Component, computed, effect, inject, input, linkedSignal, signal } from '@angular/core';
import { form, FormField, FormRoot } from '@angular/forms/signals';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
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 { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { Router } from '@angular/router';
import { round } from 'mathjs';
import moment from 'moment';
import { AuthService } from '../auth/auth.service';
import { Batch } from '../core/batch';
import { BatchService } from '../core/batch.service';
import { CostCentre } from '../core/cost-centre';
import { Inventory } from '../core/inventory';
import { User } from '../core/user';
import { Voucher } from '../core/voucher';
import { VoucherService } from '../core/voucher.service';
import { CostCentreService } from '../cost-centre/cost-centre.service';
import { ConfirmDialogComponent } from '../shared/confirm-dialog/confirm-dialog.component';
import { ErrorStateComponent } from '../shared/error-state/error-state.component';
import { LocalTimePipe } from '../shared/local-time.pipe';
import { MathService } from '../shared/math.service';
import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component';
import { IssueDialogComponent } from './issue-dialog.component';
import { IssueGridService } from './issue-grid.service';
export interface IssueItemFormData {
id: string | undefined;
quantity: number;
rate: number;
tax: number;
discount: number;
amount: number;
batch: Batch;
}
export interface IssueFormModel {
date: Date;
source: string;
destination: string;
amount: number;
addRow: {
batch: Batch | string | null;
quantity: string;
};
inventories: IssueItemFormData[];
narration: string;
posted: boolean;
creationDate: string;
lastEditDate: string;
userName: User;
poster: string;
}
@Component({
selector: 'app-issue',
templateUrl: './issue.component.html',
styleUrls: ['./issue.component.css'],
host: {
'(window:keydown.f2)': 'focusDate($event)',
'(window:keydown.f3)': 'focusBatchHost($event)',
'(window:keydown.control.s)': 'saveListner($event)',
'(window:keydown.control.p)': 'postListner($event)',
},
imports: [
FormField,
FormRoot,
MatFormFieldModule,
MatInputModule,
MatDatepickerModule,
MatSelectModule,
MatOptionModule,
MatAutocompleteModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatSortModule,
DecimalPipe,
CurrencyPipe,
LocalTimePipe,
SkeletonLoaderComponent,
ErrorStateComponent,
],
})
export class IssueComponent {
private router = inject(Router);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
auth = inject(AuthService);
private math = inject(MathService);
private ser = inject(VoucherService);
private batchSer = inject(BatchService);
private issueGridSer = inject(IssueGridService);
private costCentreSer = inject(CostCentreService);
sortActive = signal('');
sortDirection = signal('');
id = input(null, { transform: (v: string | null | undefined) => v ?? null });
d = input(null, { transform: (v: string | null | undefined) => v ?? null });
type = signal('Issue');
itemResource = this.ser.getVoucher(this.id, this.type, signal(null), this.d);
item = linkedSignal(() => this.itemResource.value() ?? new Voucher());
formModel = linkedSignal<Voucher, IssueFormModel>({
source: this.item,
computation: (voucher): IssueFormModel => {
const amount = round(
voucher.inventories.reduce((p, c) => p + c.amount, 0),
2,
);
return {
date: voucher.date ? moment(voucher.date, 'DD-MMM-YYYY').toDate() : new Date(),
source: (voucher.source as CostCentre)?.id ?? '',
destination: (voucher.destination as CostCentre)?.id ?? '',
amount,
addRow: {
batch: null as Batch | string | null,
quantity: '',
},
inventories: voucher.inventories.map((inv) => ({
id: inv.id,
quantity: inv.quantity,
rate: inv.rate,
tax: inv.tax,
discount: inv.discount,
amount: inv.amount,
batch: inv.batch,
})),
narration: voucher.narration ?? '',
posted: voucher.posted,
creationDate: voucher.creationDate,
lastEditDate: voucher.lastEditDate,
userName: voucher.user ?? new User(),
poster: voucher.poster ?? '',
};
},
});
form = form(this.formModel);
displayedColumns = ['product', 'batch', 'quantity', 'rate', 'amount', 'action'];
gridColumns = ['source', 'destination', 'gridAmount', 'load'];
balanceDate = computed(() => moment(this.form.date().value()).format('DD-MMM-YYYY'));
batchSearch = computed(() => {
const v = this.form.addRow.batch().value();
return typeof v === 'string' ? v : null;
});
batchesResource = this.batchSer.autocomplete(this.balanceDate, this.batchSearch);
batches = computed(() => this.batchesResource.value() ?? []);
costCentresResource = this.costCentreSer.list();
costCentres = computed(() => this.costCentresResource.value() ?? []);
issueGridResource = this.issueGridSer.issueGrid(this.balanceDate);
gridData = computed(() => this.issueGridResource.value() ?? []);
focusDate(event: Event) {
event.preventDefault();
this.form().focusBoundControl();
}
focusBatchHost(event: Event) {
event.preventDefault();
this.form.addRow.batch().focusBoundControl();
}
saveListner(event: Event) {
event.preventDefault();
if (this.canSave()) {
this.save();
}
}
postListner(event: Event) {
event.preventDefault();
if (this.id() && !this.form.posted().controlValue() && this.auth.allowed('post-vouchers')) {
this.post();
}
}
constructor() {
afterNextRender(() => {
this.form.addRow.batch().focusBoundControl();
});
effect(() => {
const d = this.formModel().date;
if (!d) return;
const url = new URL(window.location.href);
url.searchParams.set('d', moment(d).format('DD-MMM-YYYY'));
window.history.replaceState({}, '', url.toString());
});
}
displayFn(batch?: Batch | string): string {
return !batch ? '' : typeof batch === 'string' ? batch : batch.name;
}
addRow() {
const formValue = this.form().value().addRow;
if (!formValue || !formValue.batch || typeof formValue.batch === 'string') {
return;
}
const batch = formValue.batch as Batch;
const quantity = this.math.parseAmount(formValue.quantity ?? '0', 2);
const isConsumption = this.formModel().source === '7b845f95-dfef-fa4a-897c-f0baf15284a3';
if (quantity <= 0) {
return;
}
const currentInventories = this.formModel().inventories;
const oldIndex = currentInventories.findIndex((x) => x.batch?.id === batch.id);
let updatedInventories: IssueItemFormData[];
if (oldIndex >= 0) {
const old = currentInventories[oldIndex];
if (isConsumption && old.quantity + quantity > batch.quantityRemaining) {
this.snackBar.open('Quantity issued cannot be more than quantity available', 'Danger');
return;
}
const newQty = old.quantity + quantity;
const newAmount = round(newQty * batch.rate * (1 + batch.tax) * (1 - batch.discount), 2);
updatedInventories = currentInventories.map((inv, i) =>
i === oldIndex ? { ...inv, quantity: newQty, amount: newAmount } : inv,
);
} else {
if (isConsumption && quantity > batch.quantityRemaining) {
this.snackBar.open('Quantity issued cannot be more than quantity available', 'Danger');
return;
}
const newRow: IssueItemFormData = {
id: undefined,
quantity,
rate: batch.rate,
tax: batch.tax,
discount: batch.discount,
amount: round(quantity * batch.rate * (1 + batch.tax) * (1 - batch.discount), 2),
batch,
};
updatedInventories = [...currentInventories, newRow];
}
const totalAmount = round(
updatedInventories.reduce((p, c) => p + c.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
inventories: updatedInventories,
amount: totalAmount,
}));
this.form.addRow.batch().value.set(null);
this.form.addRow.quantity().value.set('');
this.form.addRow.batch().focusBoundControl();
}
deleteRow(row: IssueItemFormData) {
const updatedInventories = this.formModel().inventories.filter((j) => j !== row);
const totalAmount = round(
updatedInventories.reduce((p, c) => p + c.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
inventories: updatedInventories,
amount: totalAmount,
}));
this.form.addRow.batch().focusBoundControl();
}
editRow(row: IssueItemFormData) {
const dialogRef = this.dialog.open(IssueDialogComponent, {
width: '750px',
data: {
inventory: new Inventory({
id: row.id,
quantity: row.quantity,
rate: row.rate,
tax: row.tax,
discount: row.discount,
amount: row.amount,
batch: row.batch,
}),
date: moment(this.formModel().date).format('DD-MMM-YYYY'),
},
});
dialogRef.afterClosed().subscribe((result: boolean | Inventory) => {
if (!result || typeof result === 'boolean') {
return;
}
const j = result;
if (
j.batch?.sku.id !== row.batch?.sku.id &&
this.formModel().inventories.some((x) => x.batch?.id === j.batch?.id)
) {
this.snackBar.open('Product already added', 'Danger');
return;
}
const updatedInventories = this.formModel().inventories.map((item) =>
item === row
? {
...item,
quantity: j.quantity,
rate: j.rate,
tax: j.tax,
discount: j.discount,
amount: j.amount,
batch: j.batch,
}
: item,
);
const totalAmount = round(
updatedInventories.reduce((p, c) => p + c.amount, 0),
2,
);
this.formModel.update((m) => ({
...m,
inventories: updatedInventories,
amount: totalAmount,
}));
this.form.addRow.batch().focusBoundControl();
});
}
getVoucher(): Voucher {
const m = this.formModel();
const base = this.item();
const v = new Voucher({ ...base });
v.date = moment(m.date).format('DD-MMM-YYYY');
v.source = new CostCentre({ id: m.source });
v.destination = new CostCentre({ id: m.destination });
v.narration = m.narration ?? '';
v.inventories = m.inventories.map(
(inv) =>
new Inventory({
id: inv.id,
quantity: inv.quantity,
rate: inv.rate,
tax: inv.tax,
discount: inv.discount,
amount: inv.amount,
batch: inv.batch,
}),
);
return v;
}
canSave() {
if (!this.id()) {
return true;
}
if (this.form.posted().value() && !this.auth.allowed('edit-posted-vouchers')) {
return false;
}
const user = this.auth.user as User;
return this.item().user?.id === user?.id || this.auth.allowed("edit-other-user's-vouchers");
}
save() {
this.ser.saveOrUpdate(this.getVoucher()).subscribe({
next: (result) => {
this.snackBar.open('Voucher Saved', undefined, { duration: 2000 });
if (this.item().id === result.id) {
this.item.set(result);
} else {
this.router.navigate(['/issue', result.id]);
}
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
}
post() {
const id = this.id();
if (id) {
this.ser.post(id).subscribe({
next: (result) => {
this.snackBar.open('Voucher Posted', undefined, { duration: 2000 });
this.item.set(result);
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
}
}
newVoucher() {
this.router.navigate(['/issue']);
}
confirmDelete(): void {
const id = this.id();
if (!id) return;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: { title: 'Delete Voucher?', content: 'Are you sure? This cannot be undone.' },
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.delete();
}
});
}
delete() {
const id = this.id();
if (id) {
this.ser.delete(id).subscribe({
next: () => {
this.snackBar.open('Voucher Deleted', undefined, { duration: 2000 });
this.router.navigate(['/issue'], { replaceUrl: true });
},
error: (error) => {
this.snackBar.open(error, 'Danger');
},
});
}
}
goToVoucher(id: string) {
this.router.navigate(['/issue', id]);
}
sortData(sort: Sort) {
this.sortActive.set(sort.active);
this.sortDirection.set(sort.direction);
}
}