Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/web/lib/erp-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,number>(),parties=new Map<string,number>(),items=new Map<string,{name:string;qty:number;revenue:number;margin:number}>(),categories=new Map<string,number>();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<string,number>(),suppliers=new Map<string,number>();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') {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions src/lib/erpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(resource: string, query?: Record<string, string>): Promise<T> {
const params = query ? `?${new URLSearchParams(query)}` : ''
const response = await fetch(buildApiUrl(`/api/v1/${resource}${params}`))
Expand Down Expand Up @@ -128,7 +140,7 @@ export async function postErp<T>(resource: string, body: unknown): Promise<T> {
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
}
Expand All @@ -148,7 +160,7 @@ export async function patchErp<T>(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
}
Expand All @@ -163,6 +175,6 @@ export async function deleteErp(resource: string, id: string): Promise<void> {
}
await invalidateCache(resource)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('erp-resource-mutated', { detail: { resource, action: 'delete', id } }))
announceResourceMutation({ resource, action: 'delete', id })
}
}
38 changes: 34 additions & 4 deletions src/pages/transactions/PurchaseEntry.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<any[]>('parties', undefined, force ? { forceRefresh: true } : undefined),
getErp<any[]>('items', undefined, force ? { forceRefresh: true } : undefined),
Expand Down Expand Up @@ -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(() => {
Expand Down
38 changes: 34 additions & 4 deletions src/pages/transactions/SaleEntry.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -142,7 +142,7 @@ export default function SaleEntry() {
lines: [],
}

const loadCatalog = (force = false) => {
const loadCatalog = useCallback((force = false) => {
Promise.all([
getErp<any[]>('parties', undefined, force ? { forceRefresh: true } : undefined),
getErp<any[]>('items', undefined, force ? { forceRefresh: true } : undefined)
Expand Down Expand Up @@ -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()
Expand Down
Loading