feat: Implement Free Issue functionality in Shop PO and Invoice management
This commit is contained in:
@@ -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 & 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)} → {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>
|
||||||
|
|||||||
Reference in New Issue
Block a user