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({ 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); } }