443 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|