diff --git a/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx index 31c5eda..c8773c6 100644 --- a/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/[id]/edit/page.tsx @@ -31,6 +31,7 @@ import { } from '@/lib/api/index' import type { InvoiceCreationMode } from '@/lib/api/sys-config' import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api' +import { FreeIssuePanel, type FreeLine } from '../../_components/free-issue-panel' const lineItemSchema = z.object({ itemId: z.string().min(1, 'Item is required'), @@ -60,6 +61,8 @@ export default function EditShopPOPage() { const [confirmedByUserId, setConfirmedByUserId] = useState('') const [confirming, setConfirming] = useState(false) const [mode, setMode] = useState(DEFAULT_INVOICE_CREATION_MODE) + const [freeLines, setFreeLines] = useState([]) + const [discountIds, setDiscountIds] = useState([]) useEffect(() => { invoiceCreationModeApi.get().then(setMode).catch(() => {}) @@ -91,9 +94,21 @@ export default function EditShopPOPage() { setUsersList(users) form.reset({ items: poRes.items - .filter((i) => i.isActive) + .filter((i) => i.isActive && !i.isFree) .map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })), }) + setDiscountIds(poRes.discounts.map((d) => d.schemeId)) + setFreeLines( + poRes.items + .filter((i) => i.isActive && i.isFree && i.schemeId) + .map((i) => ({ + itemId: i.itemId, + itemName: i.itemName, + quantity: i.quantity, + schemeId: i.schemeId!, + schemeName: i.schemeName ?? 'Scheme', + })) + ) }) .catch((err) => { toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' }) @@ -138,7 +153,13 @@ export default function EditShopPOPage() { async function onSubmit(values: FormValues) { try { - await shopPosApi.updateItems(id, { items: values.items }) + await shopPosApi.updateItems(id, { + discountSchemeIds: discountIds, + items: [ + ...values.items, + ...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })), + ], + }) toast.add({ title: 'Shop PO updated', type: 'success' }) router.push('/sales') } catch (err) { @@ -296,6 +317,20 @@ export default function EditShopPOPage() { + ({ + itemId: l.itemId, + quantity: Number(l.quantity) || 0, + unitPrice: Number(l.unitPrice) || 0, + }))} + freeLines={freeLines} + onChange={setFreeLines} + discountIds={discountIds} + onDiscountChange={setDiscountIds} + disabled={locked} + /> +

Order Summary

diff --git a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx index 9c24006..853eac1 100644 --- a/distribution_fe/app/(dashboard)/sales/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/[id]/page.tsx @@ -83,7 +83,7 @@ export default function ShopPOViewPage() { if (!po) return null - const activeItems = po.items.filter((i) => i.isActive) + const activeItems = po.items.filter((i) => i.isActive).sort((a, b) => Number(a.isFree) - Number(b.isFree)) return (
@@ -155,6 +155,7 @@ export default function ShopPOViewPage() { Item Quantity + Free Issue Fulfilled Unit Price Line Total @@ -163,15 +164,21 @@ export default function ShopPOViewPage() { {activeItems.length === 0 ? ( - + No items on this PO. ) : ( activeItems.map((item) => ( - {item.itemName} - {item.quantity} + + {item.itemName} + {item.isFree && item.schemeName && ( + {item.schemeName} + )} + + {item.isFree ? '-' : item.quantity} + {item.isFree ? item.quantity : '-'} {item.quantityFulfilled} {fmtAmount(item.unitPrice)} {fmtAmount(item.quantity * item.unitPrice)} @@ -181,8 +188,14 @@ export default function ShopPOViewPage() { {activeItems.length > 0 && ( + {po.discounts.map((d) => ( + + Scheme discount ({d.schemeName}) + -{fmtAmount(d.amount)} + + ))} - + Total diff --git a/distribution_fe/app/(dashboard)/sales/_components/free-issue-panel.tsx b/distribution_fe/app/(dashboard)/sales/_components/free-issue-panel.tsx new file mode 100644 index 0000000..075b5f5 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/_components/free-issue-panel.tsx @@ -0,0 +1,293 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon, Delete02Icon } from '@hugeicons/core-free-icons' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { schemes as schemesApi } from '@/lib/api/index' +import type { SchemeSuggestion } from '@/types/api' + +export interface FreeLine { + itemId: string + itemName: string + quantity: number + schemeId: string + schemeName: string +} + +interface PaidLine { + itemId: string + quantity: number + unitPrice: number +} + +interface Props { + storeId: string + paidLines: PaidLine[] + freeLines: FreeLine[] + onChange: (lines: FreeLine[]) => void + /** Accepted percentage-discount scheme ids (at most one). */ + discountIds: string[] + onDiscountChange: (ids: string[]) => void + disabled?: boolean +} + +const isFreeGoods = (s: SchemeSuggestion) => s.ruleType === 'BuyXGetY' || s.ruleType === 'FreeBundle' +const isDiscount = (s: SchemeSuggestion) => s.ruleType === 'FlatPercent' || s.ruleType === 'SlabDiscount' +const money = (n: number) => `Rs. ${n.toLocaleString()}` + +/** + * Suggests free-issue schemes for the paid lines being entered. Nothing is added automatically: the user + * accepts a suggestion, after which the free line stays editable. If a paid-quantity change shrinks the + * entitlement, accepted free quantities are trimmed to match (and the user is told). + */ +export function FreeIssuePanel({ storeId, paidLines, freeLines, onChange, discountIds, onDiscountChange, disabled }: Props) { + const [suggestions, setSuggestions] = useState([]) + const freeRef = useRef(freeLines) + freeRef.current = freeLines + const discountRef = useRef(discountIds) + discountRef.current = discountIds + + const paidKey = JSON.stringify( + paidLines.filter((l) => l.itemId && l.quantity > 0).map((l) => [l.itemId, l.quantity, l.unitPrice]) + ) + + useEffect(() => { + if (!storeId) { + setSuggestions([]) + return + } + const lines = paidLines.filter((l) => l.itemId && l.quantity > 0) + if (lines.length === 0) { + setSuggestions([]) + return + } + let cancelled = false + const timer = setTimeout(() => { + schemesApi + .suggest({ + storeId, + items: lines.map((l) => ({ + itemId: l.itemId, + quantity: l.quantity, + unitPrice: l.unitPrice > 0 ? l.unitPrice : null, + })), + }) + .then((result) => { + if (cancelled) return + setSuggestions(result) + reconcile(result) + }) + .catch(() => { + // Suggestions are advisory; the PO can still be saved without them. + if (!cancelled) setSuggestions([]) + }) + }, 400) + return () => { + cancelled = true + clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storeId, paidKey]) + + // Trim or drop accepted free lines that the current paid lines no longer entitle. + function reconcile(result: SchemeSuggestion[]) { + const current = freeRef.current + let changed = false + const next: FreeLine[] = [] + for (const line of current) { + const s = result.find((r) => r.schemeId === line.schemeId) + const entitled = s?.eligible ? Math.floor(s.freeQty ?? 0) : 0 + if (entitled <= 0) { + changed = true + continue + } + const used = next.filter((n) => n.schemeId === line.schemeId).reduce((sum, n) => sum + n.quantity, 0) + const allowed = Math.max(0, entitled - used) + if (allowed <= 0) { + changed = true + continue + } + if (line.quantity > allowed) { + changed = true + next.push({ ...line, quantity: allowed }) + } else { + next.push(line) + } + } + if (changed) { + onChange(next) + toast.add({ + title: 'Free issue adjusted', + description: 'Free quantities were reduced to match what the current order qualifies for.', + type: 'warning', + }) + } + + // An accepted discount the order no longer qualifies for is dropped. + const stillValid = discountRef.current.filter((id) => result.some((r) => r.schemeId === id && r.eligible)) + if (stillValid.length !== discountRef.current.length) { + onDiscountChange(stillValid) + toast.add({ title: 'Discount removed', description: 'The order no longer qualifies for the discount.', type: 'warning' }) + } + } + + function accept(s: SchemeSuggestion, remaining: number) { + if (!s.freeItemId) return + const existing = freeLines.find((l) => l.schemeId === s.schemeId) + if (existing) { + onChange(freeLines.map((l) => (l === existing ? { ...l, quantity: l.quantity + remaining } : l))) + return + } + onChange([ + ...freeLines, + { + itemId: s.freeItemId, + itemName: s.freeItemName ?? 'Free item', + quantity: remaining, + schemeId: s.schemeId, + schemeName: s.schemeName, + }, + ]) + } + + function setQuantity(index: number, quantity: number) { + onChange(freeLines.map((l, i) => (i === index ? { ...l, quantity } : l))) + } + + const goods = suggestions.filter(isFreeGoods) + const offers = goods + .filter((s) => s.eligible && s.freeItemId) + .map((s) => { + const accepted = freeLines.filter((l) => l.schemeId === s.schemeId).reduce((sum, l) => sum + l.quantity, 0) + return { s, remaining: Math.floor(s.freeQty ?? 0) - accepted } + }) + .filter((o) => o.remaining > 0) + const nudges = goods.filter((s) => !s.eligible && s.reason) + const discountOffers = suggestions.filter((s) => isDiscount(s) && s.eligible && s.discountAmount != null) + const applied = discountOffers.filter((s) => discountIds.includes(s.schemeId)) + const available = discountOffers.filter((s) => !discountIds.includes(s.schemeId)) + const orderTotal = paidLines.reduce((sum, l) => sum + (l.quantity || 0) * (l.unitPrice || 0), 0) + + if (offers.length === 0 && nudges.length === 0 && freeLines.length === 0 && discountOffers.length === 0) return null + + return ( +
+

Offers & Free Issues

+

+ Suggested from active schemes. Add them to the order, then adjust the quantity if needed. +

+ + {(applied.length > 0 || available.length > 0) && ( +
+ {applied.map((s) => ( +
+
+

{s.schemeName}

+

+ Discount applied: -{money(s.discountAmount!)} + {' '}({money(orderTotal)} → {money(Math.max(0, orderTotal - s.discountAmount!))} on the invoice) +

+
+ +
+ ))} + {available.map((s) => ( +
+
+

{s.schemeName}

+

+ Saves {money(s.discountAmount!)} on this order +

+
+ +
+ ))} +
+ )} + + {offers.length > 0 && ( +
+ {offers.map(({ s, remaining }) => ( +
+
+

{s.schemeName}

+

+ Qualifies for {remaining} free {s.freeItemName ?? 'item'} +

+
+ +
+ ))} +
+ )} + + {nudges.length > 0 && ( +
    + {nudges.map((s) => ( +
  • + {s.schemeName}: {s.reason} +
  • + ))} +
+ )} + + {freeLines.length > 0 && ( +
+ + + + + + + + + + + {freeLines.map((line, index) => ( + + + + + + + ))} + +
Free itemSchemeFree qty
{line.itemName} + {line.schemeName} + + setQuantity(index, Math.max(1, Math.floor(Number(e.target.value) || 1)))} + /> + + +
+
+ )} +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx index 05ea17b..54ac70a 100644 --- a/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx @@ -20,11 +20,12 @@ import { invoices as invoicesApi, users as usersApi, items as itemsApi, + schemes as schemesApi, priceLists as priceListsApi, getApiError, getPaginatedItems, } from '@/lib/api/index' -import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api' +import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, SchemeSuggestion, UserResponse } from '@/types/api' interface AddedLine { key: string @@ -59,6 +60,11 @@ export default function InvoiceEditPage() { const [userId, setUserId] = useState('') const [usersList, setUsersList] = useState([]) const [saving, setSaving] = useState(false) + // Scheme discount for the lines as currently typed, recalculated by the server (null until the first answer). + const [previewDiscount, setPreviewDiscount] = useState(null) + // The percentage-discount scheme applied to this invoice's order (at most one), and the ones it currently qualifies for. + const [discountIds, setDiscountIds] = useState([]) + const [discountOffers, setDiscountOffers] = useState([]) useEffect(() => { invoicesApi.get(invoiceId) @@ -69,6 +75,7 @@ export default function InvoiceEditPage() { return } setFull(inv) + setDiscountIds(inv.discounts.map((d) => d.schemeId)) setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)]))) setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)]))) setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved]))) @@ -104,11 +111,65 @@ export default function InvoiceEditPage() { quantity: Number(a.qty) || 0, unitPrice: Number(a.price) || 0, isRemoved: false, + isFree: false, + schemeId: null, + schemeName: null, })) const items = [...existing, ...extra] - const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) - return { ...full, items, totalAmount } - }, [full, qty, price, removed, added]) + const subtotal = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) + const discountAmount = Math.min(previewDiscount ?? full.discountAmount, subtotal) + return { ...full, items, discountAmount, totalAmount: subtotal - discountAmount } + }, [full, qty, price, removed, added, previewDiscount]) + + // Adding, removing or re-pricing lines can change a percentage/slab discount, so ask the server to recalculate it. + const previewKey = JSON.stringify( + live?.items.map((i) => [i.itemId, i.quantity, i.unitPrice, i.isFree, i.isRemoved]) ?? [], + ) + useEffect(() => { + if (!full || !live) return + let cancelled = false + const timer = setTimeout(() => { + invoicesApi + .previewTotals(full.id, live.items.map((i) => ({ + itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice, isFree: i.isFree, isRemoved: i.isRemoved, schemeId: i.schemeId, + })), discountIds) + .then((r) => { + if (cancelled) return + setPreviewDiscount(r.discountAmount) + // Lowering the paid item that earned a free issue lowers the free quantity with it. + const left = new Map(r.freeEntitlements.map((e) => [e.schemeId, e.quantity])) + let reduced = false + for (const it of live.items) { + if (!it.isFree || !it.schemeId || it.isRemoved) continue + const allowed = left.get(it.schemeId) ?? 0 + if (it.quantity <= allowed) { left.set(it.schemeId, allowed - it.quantity); continue } + reduced = true + left.set(it.schemeId, 0) + if (allowed === 0) setRemoved((x) => ({ ...x, [it.id]: true })) + else setQty((x) => ({ ...x, [it.id]: String(allowed) })) + } + if (reduced) toast.add({ title: 'Free issue reduced', description: 'The free quantity was lowered to match the paid quantity.', type: 'warning' }) + }) + .catch(() => { /* keep the last known discount; the save recalculates it anyway */ }) + }, 400) + return () => { cancelled = true; clearTimeout(timer) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [full?.id, previewKey, discountIds]) + + // Which percentage discounts the lines as typed qualify for (so one can be applied or removed here). + useEffect(() => { + if (!full || !live) return + const paid = live.items.filter((i) => !i.isFree && !i.isRemoved && i.quantity > 0) + let cancelled = false + const timer = setTimeout(() => { + schemesApi + .suggest({ storeId: full.storeId, items: paid.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice > 0 ? i.unitPrice : null })) }) + .then((r) => { if (!cancelled) setDiscountOffers(r.filter((x) => (x.ruleType === 'FlatPercent' || x.ruleType === 'SlabDiscount') && x.eligible)) }) + .catch(() => { if (!cancelled) setDiscountOffers([]) }) + }, 400) + return () => { cancelled = true; clearTimeout(timer) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [full?.id, previewKey]) // Items not already on the invoice (a deleted line can be restored with Undo instead). const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id)) @@ -196,6 +257,7 @@ export default function InvoiceEditPage() { ...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })), ], userId, + discountIds, ) toast.add({ title: 'Invoice updated', type: 'success' }) // Navigating away right after a successful save; clear the unsaved-changes guard first. @@ -236,6 +298,36 @@ export default function InvoiceEditPage() {

Loading invoice…

) : ( <> + {(discountOffers.length > 0 || discountIds.length > 0) && ( +
+

Scheme discount

+ {discountOffers.map((o) => { + const applied = discountIds.includes(o.schemeId) + return ( +
+ + {o.schemeName}{' '} + saves Rs. {(o.discountAmount ?? 0).toLocaleString()} on these lines + + {applied ? ( + + ) : ( + + )} +
+ ) + })} + {discountIds.length > 0 && !discountOffers.some((o) => discountIds.includes(o.schemeId)) && ( +
+ An applied discount no longer qualifies for these lines (it counts as Rs. 0). + +
+ )} +
+ )} + ( @@ -248,7 +340,9 @@ export default function InvoiceEditPage() { : setQty((q) => ({ ...q, [item.id]: e.target.value }))} /> )} - renderUnitPrice={(item) => ( + renderUnitPrice={(item) => item.isFree ? ( + Free + ) : (
-
diff --git a/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx index 670245a..07a05a4 100644 --- a/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx @@ -199,6 +199,7 @@ export default function InvoiceViewPage() { Item Ordered Billed + Free Issue Unit price Line total @@ -206,19 +207,23 @@ export default function InvoiceViewPage() { {invoice.items.length === 0 ? ( - No items. + No items. ) : ( - invoice.items.map((i) => ( + [...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => ( {i.itemName} + {i.isFree && {i.schemeName ?? "Free issue"}} {i.orderedQuantity === null && Added} {i.isRemoved && Removed} - {i.orderedQuantity ?? '—'} - - {i.quantity} + {i.isFree ? '-' : (i.orderedQuantity ?? '-')} + + {i.isFree ? '-' : i.quantity} + + + {i.isFree ? i.quantity : '-'} {fmtAmount(i.unitPrice)} {i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)} @@ -226,6 +231,26 @@ export default function InvoiceViewPage() { )) )} + {invoice.items.length > 0 && ( + + + Subtotal + + {fmtAmount(invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0))} + + + {invoice.discountAmount > 0 && ( + + Scheme discount + -{fmtAmount(invoice.discountAmount)} + + )} + + Total + {fmtAmount(invoice.totalAmount)} + + + )}
diff --git a/distribution_fe/app/(dashboard)/sales/new/page.tsx b/distribution_fe/app/(dashboard)/sales/new/page.tsx index 2ebc9e1..e11141c 100644 --- a/distribution_fe/app/(dashboard)/sales/new/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/new/page.tsx @@ -29,6 +29,7 @@ import { getPaginatedItems, } from '@/lib/api/index' import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api' +import { FreeIssuePanel, type FreeLine } from '../_components/free-issue-panel' const lineItemSchema = z.object({ itemId: z.string().min(1, 'Item is required'), @@ -57,6 +58,8 @@ export default function NewShopPOPage() { const [usersList, setUsersList] = useState([]) const [priceMinByIndex, setPriceMinByIndex] = useState>({}) const [priceRetailByIndex, setPriceRetailByIndex] = useState>({}) + const [freeLines, setFreeLines] = useState([]) + const [discountIds, setDiscountIds] = useState([]) const form = useForm({ resolver: zodResolver(schema), @@ -125,9 +128,13 @@ export default function NewShopPOPage() { async function onSubmit(values: FormValues) { try { await shopPosApi.create({ + discountSchemeIds: discountIds, storeId: values.storeId, raisedByUserId: values.raisedByUserId, - items: values.items, + items: [ + ...values.items, + ...freeLines.map((l) => ({ itemId: l.itemId, quantity: l.quantity, isFree: true, schemeId: l.schemeId })), + ], }) toast.add({ title: 'Shop PO created', type: 'success' }) router.push('/sales') @@ -343,6 +350,19 @@ export default function NewShopPOPage() {
+ ({ + itemId: l.itemId, + quantity: Number(l.quantity) || 0, + unitPrice: Number(l.unitPrice) || 0, + }))} + freeLines={freeLines} + onChange={setFreeLines} + discountIds={discountIds} + onDiscountChange={setDiscountIds} + /> +

Order Summary

diff --git a/distribution_fe/app/(dashboard)/sales/orders/_components/invoice-action-dialog.tsx b/distribution_fe/app/(dashboard)/sales/orders/_components/invoice-action-dialog.tsx index 8859339..a924bd7 100644 --- a/distribution_fe/app/(dashboard)/sales/orders/_components/invoice-action-dialog.tsx +++ b/distribution_fe/app/(dashboard)/sales/orders/_components/invoice-action-dialog.tsx @@ -1,6 +1,8 @@ 'use client' import { useEffect, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons' import { useForm, zodResolver, z } from '@/lib/form' import { FormField } from '@/components/shared/form-field' import { @@ -164,8 +166,14 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: /> -