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'
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<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const [freeLines, setFreeLines] = useState<FreeLine[]>([])
const [discountIds, setDiscountIds] = useState<string[]>([])
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() {
</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="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -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 (
<div className="flex flex-col gap-6">
@@ -155,6 +155,7 @@ export default function ShopPOViewPage() {
<tr>
<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">Free Issue</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">Line Total</th>
@@ -163,15 +164,21 @@ export default function ShopPOViewPage() {
<tbody>
{activeItems.length === 0 ? (
<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.
</td>
</tr>
) : (
activeItems.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2">
{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">{fmtAmount(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>
{activeItems.length > 0 && (
<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">
<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
</td>
<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,
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<UserResponse[]>([])
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(() => {
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() {
<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
invoice={live}
renderQuantity={(item) => (
@@ -248,7 +340,9 @@ export default function InvoiceEditPage() {
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
/>
)}
renderUnitPrice={(item) => (
renderUnitPrice={(item) => item.isFree ? (
<span className="text-sm text-muted-foreground">Free</span>
) : (
<Input
type="number" min={0} step="0.01"
className="ml-auto h-8 w-28 text-right"
@@ -330,7 +424,7 @@ export default function InvoiceEditPage() {
</p>
<div className="flex items-center gap-2">
<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'}
</Button>
</div>
@@ -199,6 +199,7 @@ export default function InvoiceViewPage() {
<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">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-32 px-3 py-2 text-right font-medium">Line total</th>
</tr>
@@ -206,19 +207,23 @@ export default function InvoiceViewPage() {
<tbody>
{invoice.items.length === 0 ? (
<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>
) : (
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' : ''}`}>
<td className="px-3 py-2">
{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.isRemoved && <Badge variant="secondary" className="ml-2 no-underline">Removed</Badge>}
</td>
<td className="px-3 py-2 text-right">{i.orderedQuantity ?? '—'}</td>
<td className={`px-3 py-2 text-right ${!i.isRemoved && i.quantity !== i.orderedQuantity ? 'font-medium text-destructive' : ''}`}>
{i.quantity}
<td className="px-3 py-2 text-right">{i.isFree ? '-' : (i.orderedQuantity ?? '-')}</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 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 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>
@@ -226,6 +231,26 @@ export default function InvoiceViewPage() {
))
)}
</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>
</div>
</div>
@@ -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<UserResponse[]>([])
const [priceMinByIndex, setPriceMinByIndex] = 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>({
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() {
</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="w-full rounded-2xl border p-6 sm:w-96">
<h3 className="text-sm font-medium text-foreground">Order Summary</h3>
@@ -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 }:
/>
</td>
<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] }))}>
{removed[i.id] ? 'Undo' : 'Remove'}
<Button
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>
</td>
</tr>