feat: Implement Free Issue functionality in Shop PO and Invoice management

This commit is contained in:
2026-10-22 16:37:03 +05:30
parent 58247a1a0b
commit b1bda1448e
7 changed files with 509 additions and 21 deletions
@@ -31,6 +31,7 @@ import {
} from '@/lib/api/index' } from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config' import type { InvoiceCreationMode } from '@/lib/api/sys-config'
import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api' import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
import { FreeIssuePanel, type FreeLine } from '../../_components/free-issue-panel'
const lineItemSchema = z.object({ const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'), itemId: z.string().min(1, 'Item is required'),
@@ -60,6 +61,8 @@ export default function EditShopPOPage() {
const [confirmedByUserId, setConfirmedByUserId] = useState('') const [confirmedByUserId, setConfirmedByUserId] = useState('')
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE) const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
const [discountIds, setDiscountIds] = useState<string[]>([])
useEffect(() => { useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {}) invoiceCreationModeApi.get().then(setMode).catch(() => {})
@@ -91,9 +94,21 @@ export default function EditShopPOPage() {
setUsersList(users) setUsersList(users)
form.reset({ form.reset({
items: poRes.items items: poRes.items
.filter((i) => i.isActive) .filter((i) => i.isActive && !i.isFree)
.map((i) => ({ itemId: i.itemId, quantity: i.quantity, unitPrice: i.unitPrice })), .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) => { .catch((err) => {
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' }) 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) { async function onSubmit(values: FormValues) {
try { 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' }) toast.add({ title: 'Shop PO updated', type: 'success' })
router.push('/sales') router.push('/sales')
} catch (err) { } catch (err) {
@@ -296,6 +317,20 @@ export default function EditShopPOPage() {
</div> </div>
</div> </div>
<FreeIssuePanel
storeId={po.storeId}
paidLines={watchedItems.map((l) => ({
itemId: l.itemId,
quantity: Number(l.quantity) || 0,
unitPrice: Number(l.unitPrice) || 0,
}))}
freeLines={freeLines}
onChange={setFreeLines}
discountIds={discountIds}
onDiscountChange={setDiscountIds}
disabled={locked}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96"> <div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3> <h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -83,7 +83,7 @@ export default function ShopPOViewPage() {
if (!po) return null 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -155,6 +155,7 @@ export default function ShopPOViewPage() {
<tr> <tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th> <th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-right font-medium">Quantity</th> <th className="w-28 px-3 py-2 text-right font-medium">Quantity</th>
<th className="w-28 px-3 py-2 text-right font-medium">Free Issue</th>
<th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th> <th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th>
<th className="w-32 px-3 py-2 text-right font-medium">Unit Price</th> <th className="w-32 px-3 py-2 text-right font-medium">Unit Price</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th> <th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
@@ -163,15 +164,21 @@ export default function ShopPOViewPage() {
<tbody> <tbody>
{activeItems.length === 0 ? ( {activeItems.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground"> <td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">
No items on this PO. No items on this PO.
</td> </td>
</tr> </tr>
) : ( ) : (
activeItems.map((item) => ( activeItems.map((item) => (
<tr key={item.id} className="border-t"> <tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td> <td className="px-3 py-2">
<td className="px-3 py-2 text-right">{item.quantity}</td> {item.itemName}
{item.isFree && item.schemeName && (
<Badge variant="outline" className="ml-2">{item.schemeName}</Badge>
)}
</td>
<td className="px-3 py-2 text-right">{item.isFree ? '-' : item.quantity}</td>
<td className="px-3 py-2 text-right">{item.isFree ? item.quantity : '-'}</td>
<td className="px-3 py-2 text-right">{item.quantityFulfilled}</td> <td className="px-3 py-2 text-right">{item.quantityFulfilled}</td>
<td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td> <td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td> <td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
@@ -181,8 +188,14 @@ export default function ShopPOViewPage() {
</tbody> </tbody>
{activeItems.length > 0 && ( {activeItems.length > 0 && (
<tfoot> <tfoot>
{po.discounts.map((d) => (
<tr key={d.schemeId} className="border-t bg-muted/30">
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount ({d.schemeName})</td>
<td className="px-3 py-2 text-right text-sm font-medium text-foreground">-{fmtAmount(d.amount)}</td>
</tr>
))}
<tr className="border-t bg-muted/30"> <tr className="border-t bg-muted/30">
<td colSpan={4} className="px-3 py-3 text-right text-sm font-medium text-foreground"> <td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">
Total Total
</td> </td>
<td className="px-3 py-3 text-right text-base font-semibold text-foreground"> <td className="px-3 py-3 text-right text-base font-semibold text-foreground">
@@ -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<SchemeSuggestion[]>([])
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 (
<div className="rounded-2xl border p-6">
<h3 className="text-sm font-medium text-foreground">Offers &amp; Free Issues</h3>
<p className="text-sm text-muted-foreground">
Suggested from active schemes. Add them to the order, then adjust the quantity if needed.
</p>
{(applied.length > 0 || available.length > 0) && (
<div className="mt-4 flex flex-col gap-2">
{applied.map((s) => (
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
<div className="text-sm">
<p className="font-medium text-foreground">{s.schemeName}</p>
<p className="text-muted-foreground">
Discount applied: <span className="font-medium text-foreground">-{money(s.discountAmount!)}</span>
{' '}({money(orderTotal)} &rarr; {money(Math.max(0, orderTotal - s.discountAmount!))} on the invoice)
</p>
</div>
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onDiscountChange([])}>
<HugeiconsIcon icon={Delete02Icon} size={16} />
Remove
</Button>
</div>
))}
{available.map((s) => (
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
<div className="text-sm">
<p className="font-medium text-foreground">{s.schemeName}</p>
<p className="text-muted-foreground">
Saves <span className="font-medium text-foreground">{money(s.discountAmount!)}</span> on this order
</p>
</div>
<Button type="button" size="sm" disabled={disabled || discountIds.length > 0} onClick={() => onDiscountChange([s.schemeId])}>
<HugeiconsIcon icon={Add01Icon} size={16} />
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
</Button>
</div>
))}
</div>
)}
{offers.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{offers.map(({ s, remaining }) => (
<div key={s.schemeId} className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
<div className="text-sm">
<p className="font-medium text-foreground">{s.schemeName}</p>
<p className="text-muted-foreground">
Qualifies for <span className="font-medium text-foreground">{remaining} free {s.freeItemName ?? 'item'}</span>
</p>
</div>
<Button type="button" size="sm" disabled={disabled} onClick={() => accept(s, remaining)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add free
</Button>
</div>
))}
</div>
)}
{nudges.length > 0 && (
<ul className="mt-4 flex flex-col gap-1 text-xs text-muted-foreground">
{nudges.map((s) => (
<li key={s.schemeId}>
<span className="font-medium text-foreground">{s.schemeName}:</span> {s.reason}
</li>
))}
</ul>
)}
{freeLines.length > 0 && (
<div className="mt-4 overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Free item</th>
<th className="px-3 py-2 text-left font-medium">Scheme</th>
<th className="w-28 px-3 py-2 text-left font-medium">Free qty</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{freeLines.map((line, index) => (
<tr key={`${line.schemeId}-${index}`} className="border-t">
<td className="px-3 py-2">{line.itemName}</td>
<td className="px-3 py-2">
<Badge variant="outline">{line.schemeName}</Badge>
</td>
<td className="px-3 py-2">
<Input
type="number"
min={1}
disabled={disabled}
value={line.quantity}
onChange={(e) => setQuantity(index, Math.max(1, Math.floor(Number(e.target.value) || 1)))}
/>
</td>
<td className="px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => onChange(freeLines.filter((_, i) => i !== index))}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -20,11 +20,12 @@ import {
invoices as invoicesApi, invoices as invoicesApi,
users as usersApi, users as usersApi,
items as itemsApi, items as itemsApi,
schemes as schemesApi,
priceLists as priceListsApi, priceLists as priceListsApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } 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 { interface AddedLine {
key: string key: string
@@ -59,6 +60,11 @@ export default function InvoiceEditPage() {
const [userId, setUserId] = useState('') const [userId, setUserId] = useState('')
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [saving, setSaving] = useState(false) 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<number | null>(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<string[]>([])
const [discountOffers, setDiscountOffers] = useState<SchemeSuggestion[]>([])
useEffect(() => { useEffect(() => {
invoicesApi.get(invoiceId) invoicesApi.get(invoiceId)
@@ -69,6 +75,7 @@ export default function InvoiceEditPage() {
return return
} }
setFull(inv) setFull(inv)
setDiscountIds(inv.discounts.map((d) => d.schemeId))
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)]))) setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)]))) setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)])))
setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved]))) setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved])))
@@ -104,11 +111,65 @@ export default function InvoiceEditPage() {
quantity: Number(a.qty) || 0, quantity: Number(a.qty) || 0,
unitPrice: Number(a.price) || 0, unitPrice: Number(a.price) || 0,
isRemoved: false, isRemoved: false,
isFree: false,
schemeId: null,
schemeName: null,
})) }))
const items = [...existing, ...extra] const items = [...existing, ...extra]
const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) const subtotal = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0)
return { ...full, items, totalAmount } const discountAmount = Math.min(previewDiscount ?? full.discountAmount, subtotal)
}, [full, qty, price, removed, added]) 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). // 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)) 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) })), ...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
], ],
userId, userId,
discountIds,
) )
toast.add({ title: 'Invoice updated', type: 'success' }) toast.add({ title: 'Invoice updated', type: 'success' })
// Navigating away right after a successful save; clear the unsaved-changes guard first. // Navigating away right after a successful save; clear the unsaved-changes guard first.
@@ -236,6 +298,36 @@ export default function InvoiceEditPage() {
<p className="text-sm text-muted-foreground">Loading invoice…</p> <p className="text-sm text-muted-foreground">Loading invoice…</p>
) : ( ) : (
<> <>
{(discountOffers.length > 0 || discountIds.length > 0) && (
<div className="mx-auto flex w-full max-w-[210mm] flex-col gap-2 rounded-xl border p-4">
<p className="text-sm font-medium text-foreground">Scheme discount</p>
{discountOffers.map((o) => {
const applied = discountIds.includes(o.schemeId)
return (
<div key={o.schemeId} className="flex items-center justify-between gap-3 text-sm">
<span>
<span className="font-medium">{o.schemeName}</span>{' '}
<span className="text-muted-foreground">saves Rs. {(o.discountAmount ?? 0).toLocaleString()} on these lines</span>
</span>
{applied ? (
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
) : (
<Button type="button" size="sm" disabled={discountIds.length > 0} onClick={() => setDiscountIds([o.schemeId])}>
{discountIds.length > 0 ? 'Another discount applied' : 'Apply discount'}
</Button>
)}
</div>
)
})}
{discountIds.length > 0 && !discountOffers.some((o) => discountIds.includes(o.schemeId)) && (
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>An applied discount no longer qualifies for these lines (it counts as Rs. 0).</span>
<Button type="button" variant="outline" size="sm" onClick={() => setDiscountIds([])}>Remove</Button>
</div>
)}
</div>
)}
<InvoiceTemplate <InvoiceTemplate
invoice={live} invoice={live}
renderQuantity={(item) => ( renderQuantity={(item) => (
@@ -248,7 +340,9 @@ export default function InvoiceEditPage() {
: setQty((q) => ({ ...q, [item.id]: e.target.value }))} : setQty((q) => ({ ...q, [item.id]: e.target.value }))}
/> />
)} )}
renderUnitPrice={(item) => ( renderUnitPrice={(item) => item.isFree ? (
<span className="text-sm text-muted-foreground">Free</span>
) : (
<Input <Input
type="number" min={0} step="0.01" type="number" min={0} step="0.01"
className="ml-auto h-8 w-28 text-right" className="ml-auto h-8 w-28 text-right"
@@ -330,7 +424,7 @@ export default function InvoiceEditPage() {
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button> <Button type="button" variant="outline" onClick={backToInvoice}>Cancel</Button>
<Button type="button" onClick={save} disabled={saving || (!dirty && !pendingLine())}> <Button type="button" onClick={save} disabled={saving || !userId}>
{saving ? 'Saving…' : 'Save changes'} {saving ? 'Saving…' : 'Save changes'}
</Button> </Button>
</div> </div>
@@ -199,6 +199,7 @@ export default function InvoiceViewPage() {
<th className="px-3 py-2 text-left font-medium">Item</th> <th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th> <th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-24 px-3 py-2 text-right font-medium">Billed</th> <th className="w-24 px-3 py-2 text-right font-medium">Billed</th>
<th className="w-24 px-3 py-2 text-right font-medium">Free Issue</th>
<th className="w-28 px-3 py-2 text-right font-medium">Unit price</th> <th className="w-28 px-3 py-2 text-right font-medium">Unit price</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line total</th> <th className="w-32 px-3 py-2 text-right font-medium">Line total</th>
</tr> </tr>
@@ -206,19 +207,23 @@ export default function InvoiceViewPage() {
<tbody> <tbody>
{invoice.items.length === 0 ? ( {invoice.items.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td> <td colSpan={6} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
</tr> </tr>
) : ( ) : (
invoice.items.map((i) => ( [...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
<tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}> <tr key={i.id} className={`border-t ${i.isRemoved ? 'text-muted-foreground line-through' : ''}`}>
<td className="px-3 py-2"> <td className="px-3 py-2">
{i.itemName} {i.itemName}
{i.isFree && <Badge variant="outline" className="ml-2 no-underline">{i.schemeName ?? "Free issue"}</Badge>}
{i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>} {i.orderedQuantity === null && <Badge variant="outline" className="ml-2 no-underline">Added</Badge>}
{i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>} {i.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
</td> </td>
<td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td> <td className="px-3 py-2 text-right">{i.isFree ? '-' : (i.orderedQuantity ?? '-')}</td>
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}> <td className={`px-3 py-2 text-right ${!i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.quantity} {i.isFree ? '-' : i.quantity}
</td>
<td className={`px-3 py-2 text-right ${i.isFree && !i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.isFree ? i.quantity : '-'}
</td> </td>
<td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td> <td className="px-3 py-2 text-right">{fmtAmount(i.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td> <td className="px-3 py-2 text-right font-medium">{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}</td>
@@ -226,6 +231,26 @@ export default function InvoiceViewPage() {
)) ))
)} )}
</tbody> </tbody>
{invoice.items.length > 0 && (
<tfoot>
<tr className="border-t bg-muted/30">
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Subtotal</td>
<td className="px-3 py-2 text-right text-sm font-medium">
{fmtAmount(invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0))}
</td>
</tr>
{invoice.discountAmount > 0 && (
<tr className="bg-muted/30">
<td colSpan={5} className="px-3 py-2 text-right text-sm text-muted-foreground">Scheme discount</td>
<td className="px-3 py-2 text-right text-sm font-medium">-{fmtAmount(invoice.discountAmount)}</td>
</tr>
)}
<tr className="border-t bg-muted/30">
<td colSpan={5} className="px-3 py-3 text-right text-sm font-medium text-foreground">Total</td>
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">{fmtAmount(invoice.totalAmount)}</td>
</tr>
</tfoot>
)}
</table> </table>
</div> </div>
</div> </div>
@@ -29,6 +29,7 @@ import {
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api' import type { ItemSummaryResponse, StoreResponse, UserResponse } from '@/types/api'
import { FreeIssuePanel, type FreeLine } from '../_components/free-issue-panel'
const lineItemSchema = z.object({ const lineItemSchema = z.object({
itemId: z.string().min(1, 'Item is required'), itemId: z.string().min(1, 'Item is required'),
@@ -57,6 +58,8 @@ export default function NewShopPOPage() {
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({}) const [priceMinByIndex, setPriceMinByIndex] = useState<Record<number, number | null>>({})
const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({}) const [priceRetailByIndex, setPriceRetailByIndex] = useState<Record<number, number | null>>({})
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
const [discountIds, setDiscountIds] = useState<string[]>([])
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -125,9 +128,13 @@ export default function NewShopPOPage() {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await shopPosApi.create({ await shopPosApi.create({
discountSchemeIds: discountIds,
storeId: values.storeId, storeId: values.storeId,
raisedByUserId: values.raisedByUserId, 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' }) toast.add({ title: 'Shop PO created', type: 'success' })
router.push('/sales') router.push('/sales')
@@ -343,6 +350,19 @@ export default function NewShopPOPage() {
</div> </div>
</div> </div>
<FreeIssuePanel
storeId={watchedStoreId}
paidLines={watchedItems.map((l) => ({
itemId: l.itemId,
quantity: Number(l.quantity) || 0,
unitPrice: Number(l.unitPrice) || 0,
}))}
freeLines={freeLines}
onChange={setFreeLines}
discountIds={discountIds}
onDiscountChange={setDiscountIds}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-full rounded-2xl border p-6 sm:w-96"> <div className="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3> <h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -1,6 +1,8 @@
'use client' 'use client'
import { useEffect, useState } from 'react' 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 { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field' import { FormField } from '@/components/shared/form-field'
import { import {
@@ -164,8 +166,14 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
/> />
</td> </td>
<td className="px-3 py-2 text-right no-underline"> <td className="px-3 py-2 text-right no-underline">
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}> <Button
{removed[i.id] ? 'Undo' : 'Remove'} type="button" variant="ghost" size="icon-sm"
className={removed[i.id] ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
aria-label={removed[i.id] ? 'Restore line' : 'Delete line'}
title={removed[i.id] ? 'Restore line' : 'Delete line'}
onClick={() => setRemoved((r) => ({ ...r, [i.id]: !r[i.id] }))}
>
<HugeiconsIcon icon={removed[i.id] ? Undo02Icon : Delete02Icon} size={16} />
</Button> </Button>
</td> </td>
</tr> </tr>