From 3749b30af67b801a973e943ea7c516f5cec64bda Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Mon, 15 Sep 2025 08:56:10 +0530 Subject: [PATCH 1/5] feat: enable selecting batches for batched items --- models/helpers.ts | 4 +- schemas/types.ts | 2 + src/components/Controls/Link.vue | 3 + .../POS/Classic/SelectedItemRow.vue | 39 +++- .../POS/Classic/SelectedItemTable.vue | 10 + src/components/POS/types.ts | 6 +- src/pages/POS/BatchSelectionModal.vue | 106 ++++++++++ src/pages/POS/ClassicPOS.vue | 25 ++- src/pages/POS/POS.vue | 183 ++++++++++++++++-- 9 files changed, 347 insertions(+), 31 deletions(-) create mode 100644 src/pages/POS/BatchSelectionModal.vue diff --git a/models/helpers.ts b/models/helpers.ts index 800d0cd6..7389b6ef 100644 --- a/models/helpers.ts +++ b/models/helpers.ts @@ -40,7 +40,7 @@ import { safeParseFloat } from 'utils/index'; import { PriceList } from './baseModels/PriceList/PriceList'; import { InvoiceItem } from './baseModels/InvoiceItem/InvoiceItem'; import { SalesInvoiceItem } from './baseModels/SalesInvoiceItem/SalesInvoiceItem'; -import { ItemQtyMap } from 'src/components/POS/types'; +import { ItemQtyMap, POSItem } from 'src/components/POS/types'; import { ValuationMethod } from './inventory/types'; import { getRawStockLedgerEntries, @@ -1000,7 +1000,7 @@ export async function removeLoyaltyPoint(doc: Doc) { export async function validateQty( sinvDoc: SalesInvoice, - item: Item | SalesInvoiceItem | undefined, + item: Item | SalesInvoiceItem | POSItem | undefined, existingItems: InvoiceItem[] ) { if (!item) { diff --git a/schemas/types.ts b/schemas/types.ts index a5307db8..b285bbac 100644 --- a/schemas/types.ts +++ b/schemas/types.ts @@ -69,6 +69,8 @@ export interface BaseField { tab?: string; // UI Facing config, for grouping by tabs abstract?: string; // Used to mark the location of a field in an Abstract schema isCustom?: boolean; // Whether the field is a custom field + filters?: Record; + getOptions?: () => Promise<{ label: string; value: string }[]>; } export type SelectOption = { value: string; label: string }; diff --git a/src/components/Controls/Link.vue b/src/components/Controls/Link.vue index 9796b82b..bf5a7014 100644 --- a/src/components/Controls/Link.vue +++ b/src/components/Controls/Link.vue @@ -189,6 +189,9 @@ export default { return getCreateFiltersFromListViewFilters(filters); }, async getFilters() { + if (this.df.filters) { + return this.df.filters; + } const { schemaName, fieldname } = this.df; const getFilters = fyo.models[schemaName]?.filters?.[fieldname]; diff --git a/src/components/POS/Classic/SelectedItemRow.vue b/src/components/POS/Classic/SelectedItemRow.vue index f00d545a..a186236d 100644 --- a/src/components/POS/Classic/SelectedItemRow.vue +++ b/src/components/POS/Classic/SelectedItemRow.vue @@ -232,6 +232,7 @@ fieldtype: 'Link', target: 'Batch', label: t`Batch`, + filters: { item: row.item as string}, }" :value="row.batch" :border="true" @@ -300,6 +301,7 @@ export default defineComponent({ components: { Currency, Data, Float, Int, Link, Text }, props: { row: { type: SalesInvoiceItem, required: true }, + batchAdded: { type: Boolean, default: false }, }, emits: ['runSinvFormulas', 'applyPricingRule', 'selectedRow'], setup() { @@ -321,6 +323,26 @@ export default defineComponent({ profileRateSetting: null as boolean | null, }; }, + watch: { + 'row.batch': { + async handler(newBatch) { + if (newBatch) { + await this.getAvailableQtyInBatch(); + } else { + this.availableQtyInBatch = 0; + } + }, + immediate: true, + }, + batchAdded: { + handler(newValue) { + if (newValue) { + this.isExapanded = true; + } + }, + immediate: true, + }, + }, computed: { isUOMConversionEnabled(): boolean { return !!fyo.singles.InventorySettings?.enableUomConversions; @@ -373,18 +395,25 @@ export default defineComponent({ }, async getAvailableQtyInBatch(): Promise { if (!this.row.batch) { + this.availableQtyInBatch = 0; return 0; } - return ( - (await fyo.db.getStockQuantity( + try { + const qty = await fyo.db.getStockQuantity( this.row.item as string, undefined, undefined, undefined, this.row.batch - )) ?? 0 - ); + ); + + this.availableQtyInBatch = qty ?? 0; + return this.availableQtyInBatch; + } catch (error) { + this.availableQtyInBatch = 0; + return 0; + } }, getDisplayTransferQuantity() { @@ -419,7 +448,7 @@ export default defineComponent({ }, async setBatch(batch: string) { this.row.set('batch', batch); - this.availableQtyInBatch = await this.getAvailableQtyInBatch(); + await this.getAvailableQtyInBatch(); }, setSerialNumber(serialNumber: string) { if (!serialNumber) { diff --git a/src/components/POS/Classic/SelectedItemTable.vue b/src/components/POS/Classic/SelectedItemTable.vue index 9bc78eb6..89865df1 100644 --- a/src/components/POS/Classic/SelectedItemTable.vue +++ b/src/components/POS/Classic/SelectedItemTable.vue @@ -53,6 +53,7 @@ > string[], + default: () => [], + }, + }, data() { return { isExapanded: false, @@ -97,6 +104,9 @@ export default defineComponent({ }, emits: ['applyPricingRule', 'selectedRow'], computed: { + isBatchAdded() { + return (itemName: string) => this.batchAddedItems.includes(itemName); + }, ratio() { return [0.1, 0.9, 0.8, 0.8, 0.8, 0.8, 0.2]; }, diff --git a/src/components/POS/types.ts b/src/components/POS/types.ts index 1cddd609..c5458163 100644 --- a/src/components/POS/types.ts +++ b/src/components/POS/types.ts @@ -21,6 +21,7 @@ export const modalNames = [ 'PriceList', 'ItemEnquiry', 'ReturnSalesInvoice', + 'BatchSelection', ] as const; export type ModalName = typeof modalNames[number]; @@ -44,13 +45,16 @@ export type PosEmits = | 'selectedReturnInvoice' | 'saveAndContinue' | 'handlePaymentAction' - | 'setTransferClearanceDate'; + | 'setTransferClearanceDate' + | 'batchSelected'; export interface POSItem { id?: number; image?: string; name: string; rate: Money; + item: string; + batch: string; availableQty: number; unit: string; hasBatch: boolean; diff --git a/src/pages/POS/BatchSelectionModal.vue b/src/pages/POS/BatchSelectionModal.vue new file mode 100644 index 00000000..c246d2d9 --- /dev/null +++ b/src/pages/POS/BatchSelectionModal.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/pages/POS/ClassicPOS.vue b/src/pages/POS/ClassicPOS.vue index 5612488f..5392e7b9 100644 --- a/src/pages/POS/ClassicPOS.vue +++ b/src/pages/POS/ClassicPOS.vue @@ -19,6 +19,13 @@ @set-loyalty-points="(points) => emitEvent('setLoyaltyPoints', points)" /> + + @@ -179,6 +184,7 @@ /> @@ -395,6 +401,7 @@ import SelectedItemTable from 'src/components/POS/Classic/SelectedItemTable.vue' import FloatingLabelFloatInput from 'src/components/POS/FloatingLabelFloatInput.vue'; import FloatingLabelCurrencyInput from 'src/components/POS/FloatingLabelCurrencyInput.vue'; import { AppliedCouponCodes } from 'models/baseModels/AppliedCouponCodes/AppliedCouponCodes'; +import BatchSelectionModal from 'src/pages/POS/BatchSelectionModal.vue'; export default defineComponent({ name: 'ClassicPOS', @@ -418,6 +425,7 @@ export default defineComponent({ FloatingLabelFloatInput, ReturnSalesInvoiceModal, FloatingLabelCurrencyInput, + BatchSelectionModal, }, props: { paidAmount: Money, @@ -435,6 +443,7 @@ export default defineComponent({ openLoyaltyProgramModal: Boolean, openAppliedCouponsModal: Boolean, openReturnSalesInvoiceModal: Boolean, + openBatchSelectionModal: Boolean, totalQuantity: { type: Number, default: 0, @@ -480,6 +489,14 @@ export default defineComponent({ required: false, default: null, }, + batchAddedItems: { + type: Array as () => string[], + default: () => [], + }, + selectedItemForBatch: { + type: String, + default: '', + }, }, emits: [ 'addItem', @@ -505,11 +522,13 @@ export default defineComponent({ 'saveAndContinue', 'handlePaymentAction', 'selectedRow', + 'batchSelected', ], data() { return { itemGroupFilter: '', additionalDiscounts: fyo.pesa(0), + // selectedItemForBatch: '' as string, }; }, computed: { diff --git a/src/pages/POS/POS.vue b/src/pages/POS/POS.vue index 5d4be2b9..39551df5 100644 --- a/src/pages/POS/POS.vue +++ b/src/pages/POS/POS.vue @@ -40,6 +40,9 @@ :open-loyalty-program-modal="openLoyaltyProgramModal" :open-applied-coupons-modal="openAppliedCouponsModal" :open-return-sales-invoice-modal="openReturnSalesInvoiceModal" + :open-batch-selection-modal="openBatchSelectionModal" + :selected-item-for-batch="selectedItemForBatch" + :batch-added-items="batchAddedItems" @add-item="addItem" @toggle-view="toggleView" @set-sinv-doc="setSinvDoc" @@ -64,6 +67,7 @@ @save-and-continue="handleSaveAndContinue" @handle-payment-action="handlePaymentAction" @selected-row="setQuickQtySelectedRow" + @batch-selected="handleBatchSelected" /> void) | null, quickQtyKeyUpHandler: null as ((e: KeyboardEvent) => void) | null, + selectedItemForBatch: '' as string, + pendingBatchItem: null as { item: POSItem; quantity: number } | null, + batchAddedItems: [] as string[], }; }, computed: { @@ -416,7 +422,7 @@ export default defineComponent({ } // Determine target row: prefer explicitly selected row; else fallback to last non-free item - let row: SalesInvoiceItem | null = this.quickQtyRow; + let row = this.quickQtyRow as SalesInvoiceItem | null; if (!row || !(this.sinvDoc.items || []).includes(row)) { const items = (this.sinvDoc.items || []).filter((r) => !r.isFreeItem); row = items.length @@ -443,11 +449,11 @@ export default defineComponent({ try { await row.set('quantity', qty); - const existingItems = - (this.sinvDoc.items || []).filter( - (invoiceItem: InvoiceItem) => - invoiceItem.item === row!.item && !invoiceItem.isFreeItem - ) || []; + const existingItems = (this.sinvDoc.items || []).filter( + (invoiceItem) => + (invoiceItem as InvoiceItem).item === row!.item && + !(invoiceItem as InvoiceItem).isFreeItem + ) as InvoiceItem[]; await validateQty( this.sinvDoc as SalesInvoice, @@ -719,6 +725,8 @@ export default defineComponent({ unit: item.unit as string, hasBatch: !!item.hasBatch, hasSerialNumber: !!item.hasSerialNumber, + item: '', + batch: '', }); } }, @@ -759,6 +767,13 @@ export default defineComponent({ }, async setItemQtyMap() { this.itemQtyMap = await getItemQtyMap(this.sinvDoc as SalesInvoice); + for (const itemName in this.itemQtyMap) { + const itemIndex = this.items.findIndex((i) => i.name === itemName); + if (itemIndex !== -1) { + this.items[itemIndex].availableQty = + this.itemQtyMap[itemName].availableQty; + } + } }, setSinvDoc() { this.sinvDoc = this.fyo.doc.getNewDoc(ModelNameEnum.SalesInvoice, { @@ -833,7 +848,7 @@ export default defineComponent({ ); } }, - async addItem(item: POSItem | Item | undefined, quantity?: number) { + async addItem(item: POSItem | undefined, quantity?: number) { try { await this.sinvDoc.runFormulas(); this.validateInvoice(); @@ -842,15 +857,23 @@ export default defineComponent({ return; } + if (item.hasBatch) { + this.selectedItemForBatch = item.name; + this.pendingBatchItem = { item, quantity: quantity ?? 1 }; + + this.toggleModal('BatchSelection', true); + + return; + } + const isInventoryItem = await this.fyo.getValue( ModelNameEnum.Item, - item.name as string, + item.name, 'trackItem' ); if (isInventoryItem) { - const availableQty = - this.itemQtyMap[item.name as string]?.availableQty ?? 0; + const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0; if (availableQty <= 0) { throw new ValidationError( t`Item is out of stock (quantity is zero)` @@ -866,13 +889,13 @@ export default defineComponent({ await validateQty( this.sinvDoc as SalesInvoice, - item as Item, + item, existingItems as InvoiceItem[] ); const itemsHsncode = (await this.fyo.getValue( 'Item', - item?.name as string, + item?.name, 'hsnCode' )) as number; @@ -903,7 +926,7 @@ export default defineComponent({ } await this.sinvDoc.append('items', { - rate: item.rate as Money, + rate: item.rate, item: item.name, quantity: addQty, hsnCode: itemsHsncode, @@ -915,14 +938,13 @@ export default defineComponent({ if (existingItems.length) { if (!this.sinvDoc.priceList) { - existingItems[0].rate = item.rate as Money; + existingItems[0].rate = item.rate; } const currentQty = existingItems[0].quantity ?? 0; const addQty = quantity ?? 1; if (isInventoryItem) { - const availableQty = - this.itemQtyMap[item.name as string]?.availableQty ?? 0; + const availableQty = this.itemQtyMap[item.name]?.availableQty ?? 0; if (currentQty + addQty > availableQty) { throw new ValidationError( 'Cannot add more than the available quantity' @@ -934,9 +956,13 @@ export default defineComponent({ await this.applyPricingRule(); await this.sinvDoc.runFormulas(); if (isInventoryItem) { + const completeItem = (await this.fyo.doc.getDoc( + ModelNameEnum.Item, + item.name + )) as Item; await validateQty( this.sinvDoc as SalesInvoice, - item as Item, + completeItem, existingItems as InvoiceItem[] ); } @@ -944,7 +970,7 @@ export default defineComponent({ } await this.sinvDoc.append('items', { - rate: item.rate as Money, + rate: item.rate, item: item.name, quantity: quantity ? quantity : 1, hsnCode: itemsHsncode, @@ -971,6 +997,122 @@ export default defineComponent({ }); } }, + async handleBatchSelected(batchName: string) { + if (!this.pendingBatchItem) { + return; + } + + const { item, quantity } = this.pendingBatchItem; + this.pendingBatchItem = null; + + try { + const isInventoryItem = await this.fyo.getValue( + ModelNameEnum.Item, + item.name, + 'trackItem' + ); + if (!this.batchAddedItems.includes(item.name)) { + this.batchAddedItems.push(item.name); + } + + let availableQty = 0; + if (isInventoryItem) { + availableQty = + (await fyo.db.getStockQuantity( + item.name, + undefined, + undefined, + undefined, + batchName + )) ?? 0; + + const itemIndex = this.items.findIndex((i) => i.name === item.name); + if (itemIndex !== -1) { + this.items[itemIndex].availableQty = availableQty ?? 0; + } + } + + const existingItems = + this.sinvDoc.items?.filter( + (invoiceItem) => + invoiceItem.item === item.name && + invoiceItem.batch === batchName && + !invoiceItem.isFreeItem + ) ?? []; + + const completeItem = (await this.fyo.doc.getDoc( + ModelNameEnum.Item, + item.name + )) as Item; + + await validateQty( + this.sinvDoc as SalesInvoice, + completeItem, + existingItems as InvoiceItem[] + ); + + const itemsHsncode = (await this.fyo.getValue( + 'Item', + item?.name, + 'hsnCode' + )) as number; + + if (existingItems.length) { + const currentQty = existingItems[0].quantity ?? 0; + const addQty = quantity ?? 1; + + await existingItems[0].set('quantity', currentQty + addQty); + } else { + await this.sinvDoc.append('items', { + rate: item.rate as Money, + item: item.name, + quantity: quantity ?? 1, + hsnCode: itemsHsncode, + batch: batchName, + }); + } + + await this.applyPricingRule(); + await this.sinvDoc.runFormulas(); + + await this.setItemQtyMap(); + } catch (error) { + showToast({ + type: 'error', + message: t`${error as string}`, + }); + } + }, + + async updateAvailableQtyInBatch(itemName: string, batchName: string) { + const itemRow = this.sinvDoc.items?.find( + (item) => item.item === itemName && item.batch === batchName + ); + + if (itemRow) { + await itemRow.set('batch', batchName); + } + }, + + async getAvailableQtyInBatch( + itemName: string, + batchName: string + ): Promise { + try { + const qty = await fyo.db.getStockQuantity( + itemName, + undefined, + undefined, + undefined, + batchName + ); + + return qty ?? 0; + } catch (error) { + return 0; + } + }, + async createTransaction(shouldPrint = false, isPay = false) { try { this.sinvDoc.date = new Date(); @@ -1141,6 +1283,7 @@ export default defineComponent({ async clearValues() { this.setSinvDoc(); this.itemSerialNumbers = {}; + this.batchAddedItems = []; this.paidAmount = fyo.pesa(0); this.transferAmount = fyo.pesa(0); From 6a72d518996f747f0e6469686956b864596d06db Mon Sep 17 00:00:00 2001 From: Gadha2311 Date: Mon, 15 Sep 2025 17:03:54 +0530 Subject: [PATCH 2/5] fix: Add batch selection header with dark mode support --- src/pages/POS/BatchSelectionModal.vue | 4 +++- src/pages/POS/ClassicPOS.vue | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/POS/BatchSelectionModal.vue b/src/pages/POS/BatchSelectionModal.vue index c246d2d9..8b70ea37 100644 --- a/src/pages/POS/BatchSelectionModal.vue +++ b/src/pages/POS/BatchSelectionModal.vue @@ -1,6 +1,8 @@