From 71a2f02fbd0479e13cf2df586807a48bd7fb792b Mon Sep 17 00:00:00 2001 From: OmegaLSKJ Date: Wed, 23 Sep 2026 17:53:31 +0530 Subject: [PATCH 1/3] Refresh purchase catalog on product changes --- src/lib/erpApi.ts | 18 +++++++++-- src/pages/transactions/PurchaseEntry.tsx | 38 +++++++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/lib/erpApi.ts b/src/lib/erpApi.ts index 94b6865..ef2884e 100644 --- a/src/lib/erpApi.ts +++ b/src/lib/erpApi.ts @@ -25,6 +25,18 @@ function buildApiUrl(path: string): string { return `http://127.0.0.1:3000${path}` } +function announceResourceMutation(detail: { resource: string; action: 'create' | 'update' | 'delete'; id?: string }): void { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent('erp-resource-mutated', { detail })) + try { + const channel = new BroadcastChannel('erp-resource-mutations') + channel.postMessage(detail) + channel.close() + } catch { + // BroadcastChannel is unavailable in a few older browsers; the current window still refreshes. + } +} + async function fetchFromNetwork(resource: string, query?: Record): Promise { const params = query ? `?${new URLSearchParams(query)}` : '' const response = await fetch(buildApiUrl(`/api/v1/${resource}${params}`)) @@ -128,7 +140,7 @@ export async function postErp(resource: string, body: unknown): Promise { registerHsnCodesFromDb([payload.data]) } if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('erp-resource-mutated', { detail: { resource, action: 'create' } })) + announceResourceMutation({ resource, action: 'create' }) } return payload.data as T } @@ -148,7 +160,7 @@ export async function patchErp(resource: string, id: string, body: unknown): registerHsnCodesFromDb([payload.data]) } if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('erp-resource-mutated', { detail: { resource, action: 'update', id } })) + announceResourceMutation({ resource, action: 'update', id }) } return payload.data as T } @@ -163,6 +175,6 @@ export async function deleteErp(resource: string, id: string): Promise { } await invalidateCache(resource) if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('erp-resource-mutated', { detail: { resource, action: 'delete', id } })) + announceResourceMutation({ resource, action: 'delete', id }) } } diff --git a/src/pages/transactions/PurchaseEntry.tsx b/src/pages/transactions/PurchaseEntry.tsx index 7fdffd3..838d03c 100644 --- a/src/pages/transactions/PurchaseEntry.tsx +++ b/src/pages/transactions/PurchaseEntry.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { Search, Plus, Trash2, Save, Printer, Minus, Pill, X, ShoppingBag, Hash, ArrowLeft, Edit2, ExternalLink, Info } from 'lucide-react' import { cn, formatCurrency } from '../../lib/utils' @@ -122,7 +122,7 @@ export default function PurchaseEntry() { const addToast = useUIStore((s) => s.addToast) // Fetch initial suppliers, items, and HSN codes - const loadSuppliersAndItems = (force = false) => { + const loadSuppliersAndItems = useCallback((force = false) => { Promise.all([ getErp('parties', undefined, force ? { forceRefresh: true } : undefined), getErp('items', undefined, force ? { forceRefresh: true } : undefined), @@ -182,11 +182,41 @@ export default function PurchaseEntry() { ) }) .catch((error) => addToast(error.message, 'error')) - } + }, [addToast]) useEffect(() => { loadSuppliersAndItems(false) - }, [addToast]) + }, [loadSuppliersAndItems]) + + // Keep the purchase product picker live when an item is added or changed in + // another ERP window, and periodically reconcile direct Supabase updates. + useEffect(() => { + const refreshCatalog = (event?: Event) => { + const mutation = (event as CustomEvent<{ resource?: string }> | undefined)?.detail + if (!mutation || mutation.resource === 'items' || mutation.resource === 'item-batches') { + loadSuppliersAndItems(true) + } + } + const refreshOnFocus = () => loadSuppliersAndItems(true) + const refreshWhenVisible = () => { + if (document.visibilityState === 'visible') refreshOnFocus() + } + const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('erp-resource-mutations') : null + + window.addEventListener('erp-resource-mutated', refreshCatalog) + window.addEventListener('focus', refreshOnFocus) + document.addEventListener('visibilitychange', refreshWhenVisible) + if (channel) channel.onmessage = refreshCatalog + const intervalId = window.setInterval(() => loadSuppliersAndItems(true), 15000) + + return () => { + window.removeEventListener('erp-resource-mutated', refreshCatalog) + window.removeEventListener('focus', refreshOnFocus) + document.removeEventListener('visibilitychange', refreshWhenVisible) + window.clearInterval(intervalId) + channel?.close() + } + }, [loadSuppliersAndItems]) // Load existing purchase bill if in edit mode useEffect(() => { From 04b09385927e45f0bd621e27c350dae98f281ce2 Mon Sep 17 00:00:00 2001 From: OmegaLSKJ Date: Wed, 23 Sep 2026 18:05:58 +0530 Subject: [PATCH 2/3] Refresh sale catalog on product changes --- src/pages/transactions/SaleEntry.tsx | 38 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/pages/transactions/SaleEntry.tsx b/src/pages/transactions/SaleEntry.tsx index b1327e1..d7fc9d7 100644 --- a/src/pages/transactions/SaleEntry.tsx +++ b/src/pages/transactions/SaleEntry.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' import { Search, Plus, Save, Printer, Trash2, X, Minus, Pill, ShoppingBag, ArrowLeft, Edit2, ExternalLink, Info } from 'lucide-react' import { cn, formatCurrency } from '../../lib/utils' @@ -142,7 +142,7 @@ export default function SaleEntry() { lines: [], } - const loadCatalog = (force = false) => { + const loadCatalog = useCallback((force = false) => { Promise.all([ getErp('parties', undefined, force ? { forceRefresh: true } : undefined), getErp('items', undefined, force ? { forceRefresh: true } : undefined) @@ -251,11 +251,41 @@ export default function SaleEntry() { ) }) .catch((error) => showToast(error.message)) - } + }, [showToast]) useEffect(() => { loadCatalog(false) - }, [showToast]) + }, [loadCatalog]) + + // Immediately receive item changes from this or another ERP window. The + // interval also catches changes made directly in Supabase. + useEffect(() => { + const refreshCatalog = (event?: Event) => { + const mutation = (event as CustomEvent<{ resource?: string }> | undefined)?.detail + if (!mutation || mutation.resource === 'items' || mutation.resource === 'item-batches') { + loadCatalog(true) + } + } + const refreshOnFocus = () => loadCatalog(true) + const refreshWhenVisible = () => { + if (document.visibilityState === 'visible') refreshOnFocus() + } + const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('erp-resource-mutations') : null + + window.addEventListener('erp-resource-mutated', refreshCatalog) + window.addEventListener('focus', refreshOnFocus) + document.addEventListener('visibilitychange', refreshWhenVisible) + if (channel) channel.onmessage = refreshCatalog + const intervalId = window.setInterval(() => loadCatalog(true), 15000) + + return () => { + window.removeEventListener('erp-resource-mutated', refreshCatalog) + window.removeEventListener('focus', refreshOnFocus) + document.removeEventListener('visibilitychange', refreshWhenVisible) + window.clearInterval(intervalId) + channel?.close() + } + }, [loadCatalog]) const getPrintData = (): TaxInvoicePrintData => { const custClean = (customer || '').trim().toLowerCase() From 4412c0f2e71dfd8e05cf71eabdad1aaa0d589de4 Mon Sep 17 00:00:00 2001 From: OmegaLSKJ Date: Wed, 23 Sep 2026 22:14:00 +0530 Subject: [PATCH 3/3] Sync Item Master stock to inventory ledger --- apps/web/lib/erp-store.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/lib/erp-store.ts b/apps/web/lib/erp-store.ts index 48e9cbe..d9466d7 100644 --- a/apps/web/lib/erp-store.ts +++ b/apps/web/lib/erp-store.ts @@ -1683,7 +1683,7 @@ export async function list(resource: string, partyName?: string, options?: { man } } if (resource === 'report-financial') { const { data, error } = await client.from('erp_trial_balance').select('*').eq('organization_id', organizationId).order('name'); if (error) throw error; return (data ?? []).map((x: any) => ({ ledger: x.name, group: x.account_group, debit: Number(x.debit), credit: Number(x.credit), balance: Number(x.balance) })) } - if (resource === 'report-stock') { const { data, error } = await client.from('erp_stock_position').select('*').eq('organization_id', organizationId).order('item_name'); if (error) throw error; return (data ?? []).map((x: any) => ({ name: x.item_name, batch: x.batch_number, expiry: x.expiry_on ?? '', qty: Number(x.quantity), reserved: Number(x.reserved_quantity), location: x.warehouse_name, schedule: x.schedule_class, recalled: x.is_recalled, mrp:Number(x.mrp), rate:Number(x.purchase_rate) })) } + if (resource === 'report-stock' || resource === 'stock') { const { data, error } = await client.from('erp_stock_position').select('*').eq('organization_id', organizationId).order('item_name'); if (error) throw error; return (data ?? []).map((x: any) => ({ name: x.item_name, batch: x.batch_number, expiry: x.expiry_on ?? '', qty: Number(x.quantity), reserved: Number(x.reserved_quantity), location: x.warehouse_name, schedule: x.schedule_class, recalled: x.is_recalled, mrp:Number(x.mrp), rate:Number(x.purchase_rate) })) } if (resource === 'report-sales') { const { data,error }=await client.from('sales_invoices').select('invoice_date,grand_total,parties(legal_name),sales_invoice_lines(quantity,line_total,items(name,salts(category)))').eq('organization_id',organizationId).neq('status','cancelled');if(error)throw error;const months=new Map(),parties=new Map(),items=new Map(),categories=new Map();for(const invoice of data??[]){const month=String(invoice.invoice_date).slice(0,7);months.set(month,(months.get(month)??0)+Number(invoice.grand_total));const party=(invoice.parties as any)?.legal_name??'Unknown';parties.set(party,(parties.get(party)??0)+Number(invoice.grand_total));for(const line of (invoice.sales_invoice_lines as any[])??[]){const name=line.items?.name??'Unknown',revenue=Number(line.line_total),current=items.get(name)??{name,qty:0,revenue:0,margin:0};current.qty+=Number(line.quantity);current.revenue+=revenue;items.set(name,current);const category=line.items?.salts?.category??'Uncategorised';categories.set(category,(categories.get(category)??0)+revenue)}}return{monthlySales:[...months].sort().map(([month,value])=>({month,value})),topParties:[...parties].sort((a,b)=>b[1]-a[1]).slice(0,10).map(([name,sales])=>({name,sales,growth:0})),topItems:[...items.values()].sort((a,b)=>b.revenue-a.revenue).slice(0,10),categories:[...categories].map(([name,value])=>({name,value})),units:[...items.values()].reduce((n,x)=>n+x.qty,0)} } if (resource === 'report-purchases') { const { data,error }=await client.from('purchase_invoices').select('invoice_date,grand_total,parties(legal_name)').eq('organization_id',organizationId).neq('status','cancelled');if(error)throw error;const months=new Map(),suppliers=new Map();for(const row of data??[]){const month=String(row.invoice_date).slice(0,7);months.set(month,(months.get(month)??0)+Number(row.grand_total));const name=(row.parties as any)?.legal_name??'Unknown';suppliers.set(name,(suppliers.get(name)??0)+Number(row.grand_total))}return{monthlyPurchases:[...months].sort().map(([month,value])=>({month,value})),topSuppliers:[...suppliers].sort((a,b)=>b[1]-a[1]).slice(0,10).map(([name,purchases])=>({name,purchases,growth:0})),activeSuppliers:suppliers.size} } if (resource === 'parties') { @@ -3164,7 +3164,6 @@ export async function create(resource: string, body: any, actor: MutationActor = } const { data, error } = await client.from('items').insert({ organization_id: organizationId, code: body.code || `ITM-${Date.now()}`, name: body.name, packing: body.packing || null, unit: body.unit || null, manufacturer_id: manufacturerId ?? null, salt_id: saltId ?? null, hsn_id: hsnId ?? null, mrp: Number(body.mrp || 0), sale_rate: Number(body.saleRate || 0), purchase_rate: Number(body.purchaseRate || 0), is_active: body.status !== 'banned', schedule_class:body.scheduleClass || 'OTC', prescription_required:Boolean(body.prescriptionRequired), cold_chain:Boolean(body.coldChain), controlled_substance:Boolean(body.controlledSubstance), is_recalled:Boolean(body.recalled) }).select('id,code').single() if (error) throw error - const { totalStock: syncedStock, batches: syncedBatches } = await syncItemBatchesAndStock( client, organizationId, @@ -3952,7 +3951,6 @@ export async function update(resource: string, id: string, body: any, actor: Mut } const { data, error } = await client.from('items').update(values).eq('id', id).eq('organization_id', organizationId).select('*').single() if (error) throw error - let syncedStock: number | undefined let syncedBatches: any[] | undefined