Fix invoice editing
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ArrowLeft01Icon, Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
invoices as invoicesApi,
|
||||
users as usersApi,
|
||||
items as itemsApi,
|
||||
priceLists as priceListsApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api'
|
||||
|
||||
interface AddedLine {
|
||||
key: string
|
||||
itemId: string
|
||||
name: string
|
||||
qty: string
|
||||
price: string
|
||||
}
|
||||
|
||||
const NEW_PREFIX = 'new:'
|
||||
const isNew = (item: InvoiceItemResponse) => item.id.startsWith(NEW_PREFIX)
|
||||
const addedKey = (item: InvoiceItemResponse) => item.id.slice(NEW_PREFIX.length)
|
||||
|
||||
// Full-page editor for a Draft invoice, on the invoice template: change quantity/price, delete (strike)
|
||||
// lines and add items that were not on the PO. Each save is recorded as a new invoice version.
|
||||
export default function InvoiceEditPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams<{ id: string }>()
|
||||
const invoiceId = params.id
|
||||
const backToInvoice = () => router.push(`/sales/invoices/${invoiceId}`)
|
||||
|
||||
const [full, setFull] = useState<InvoiceResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [qty, setQty] = useState<Record<string, string>>({})
|
||||
const [price, setPrice] = useState<Record<string, string>>({})
|
||||
const [removed, setRemoved] = useState<Record<string, boolean>>({})
|
||||
const [added, setAdded] = useState<AddedLine[]>([])
|
||||
const [catalogue, setCatalogue] = useState<ItemSummaryResponse[]>([])
|
||||
const [pickItemId, setPickItemId] = useState('')
|
||||
const [pickQty, setPickQty] = useState('1')
|
||||
const [pickPrice, setPickPrice] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
invoicesApi.get(invoiceId)
|
||||
.then((inv) => {
|
||||
if (inv.status !== 'Draft') {
|
||||
toast.add({ title: 'Only Draft invoices can be edited', type: 'error' })
|
||||
router.replace(`/sales/invoices/${invoiceId}`)
|
||||
return
|
||||
}
|
||||
setFull(inv)
|
||||
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])))
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
|
||||
router.push('/sales/orders')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
usersApi.list()
|
||||
.then(setUsersList)
|
||||
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
|
||||
itemsApi.list({ pageSize: 200 })
|
||||
.then((res) => setCatalogue(getPaginatedItems(res)))
|
||||
.catch((err) => toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }))
|
||||
}, [invoiceId, router])
|
||||
|
||||
// The template renders this copy so line amounts and the total follow what is typed.
|
||||
const live = useMemo<InvoiceResponse | null>(() => {
|
||||
if (!full) return null
|
||||
const existing = full.items.map((i) => ({
|
||||
...i,
|
||||
quantity: Number(qty[i.id]) || 0,
|
||||
unitPrice: Number(price[i.id]) || 0,
|
||||
isRemoved: !!removed[i.id],
|
||||
}))
|
||||
const extra: InvoiceItemResponse[] = added.map((a) => ({
|
||||
id: NEW_PREFIX + a.key,
|
||||
shopPOItemId: null,
|
||||
itemId: a.itemId,
|
||||
itemName: a.name,
|
||||
orderedQuantity: null,
|
||||
quantity: Number(a.qty) || 0,
|
||||
unitPrice: Number(a.price) || 0,
|
||||
isRemoved: false,
|
||||
}))
|
||||
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])
|
||||
|
||||
// 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))
|
||||
|
||||
// Picking an item fills in its price: the store's price list, falling back to the item's default price.
|
||||
async function pickItem(itemId: string) {
|
||||
setPickItemId(itemId)
|
||||
const item = catalogue.find((c) => c.id === itemId)
|
||||
if (!item || !full) return
|
||||
setPickPrice(String(item.salePrice))
|
||||
try {
|
||||
const res = await priceListsApi.resolve({
|
||||
itemId,
|
||||
storeId: full.storeId,
|
||||
saleDate: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
setPickPrice(String(res.price))
|
||||
} catch {
|
||||
// No price list entry for this store — keep the default price.
|
||||
}
|
||||
}
|
||||
|
||||
// The line currently typed into the add row, or null when nothing valid is picked.
|
||||
function pendingLine(): AddedLine | null {
|
||||
const item = catalogue.find((c) => c.id === pickItemId)
|
||||
if (!item || !(Number(pickQty) > 0) || pickPrice === '' || Number(pickPrice) < 0) return null
|
||||
return { key: crypto.randomUUID(), itemId: item.id, name: item.name, qty: pickQty, price: pickPrice }
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const line = pendingLine()
|
||||
if (!line) {
|
||||
toast.add({ title: 'Pick an item and enter a quantity above zero', type: 'error' })
|
||||
return
|
||||
}
|
||||
setAdded((a) => [...a, line])
|
||||
setPickItemId('')
|
||||
setPickQty('1')
|
||||
setPickPrice('')
|
||||
}
|
||||
|
||||
const changeSummary = useMemo(() => {
|
||||
if (!full) return []
|
||||
const edited = full.items.filter(
|
||||
(i) => !removed[i.id] && (Number(qty[i.id]) !== i.quantity || Number(price[i.id]) !== i.unitPrice),
|
||||
).length
|
||||
const struck = full.items.filter((i) => !!removed[i.id] !== i.isRemoved).length
|
||||
return [
|
||||
added.length && `${added.length} added`,
|
||||
struck && `${struck} removed/restored`,
|
||||
edited && `${edited} edited`,
|
||||
].filter(Boolean) as string[]
|
||||
}, [full, qty, price, removed, added])
|
||||
|
||||
const dirty = changeSummary.length > 0
|
||||
|
||||
// Warn before the tab is closed/reloaded with unsaved edits.
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty])
|
||||
|
||||
async function save() {
|
||||
if (!full) return
|
||||
if (!userId) {
|
||||
toast.add({ title: 'Select who is editing', type: 'error' })
|
||||
return
|
||||
}
|
||||
// An item picked in the add row but not yet added is included, so it is not silently lost.
|
||||
const pending = pendingLine()
|
||||
const newLines = pending ? [...added, pending] : added
|
||||
setSaving(true)
|
||||
try {
|
||||
await invoicesApi.updateItems(
|
||||
full.id,
|
||||
[
|
||||
...full.items.map((i) => ({
|
||||
invoiceItemId: i.id,
|
||||
quantity: Number(qty[i.id]),
|
||||
unitPrice: Number(price[i.id]),
|
||||
isRemoved: !!removed[i.id],
|
||||
})),
|
||||
...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })),
|
||||
],
|
||||
userId,
|
||||
)
|
||||
toast.add({ title: 'Invoice updated', type: 'success' })
|
||||
// Navigating away right after a successful save; clear the unsaved-changes guard first.
|
||||
setAdded([])
|
||||
setQty(Object.fromEntries(full.items.map((i) => [i.id, String(i.quantity)])))
|
||||
setPrice(Object.fromEntries(full.items.map((i) => [i.id, String(i.unitPrice)])))
|
||||
setRemoved(Object.fromEntries(full.items.map((i) => [i.id, i.isRemoved])))
|
||||
backToInvoice()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update invoice', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" size="sm" onClick={backToInvoice}>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Edit Draft Invoice</h1>
|
||||
{full && <Badge variant="secondary">{full.status}</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Change quantities and prices, delete lines, or add items. Quantity on a PO line cannot exceed the
|
||||
ordered quantity. Every save creates a new version.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || !live ? (
|
||||
<p className="text-sm text-muted-foreground">Loading invoice…</p>
|
||||
) : (
|
||||
<>
|
||||
<InvoiceTemplate
|
||||
invoice={live}
|
||||
renderQuantity={(item) => (
|
||||
<Input
|
||||
type="number" min={0} max={item.orderedQuantity ?? undefined}
|
||||
className="ml-auto h-8 w-20 text-right"
|
||||
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.qty ?? '' : qty[item.id] ?? ''}
|
||||
onChange={(e) => isNew(item)
|
||||
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, qty: e.target.value } : l))
|
||||
: setQty((q) => ({ ...q, [item.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
renderUnitPrice={(item) => (
|
||||
<Input
|
||||
type="number" min={0} step="0.01"
|
||||
className="ml-auto h-8 w-28 text-right"
|
||||
value={isNew(item) ? added.find((a) => a.key === addedKey(item))?.price ?? '' : price[item.id] ?? ''}
|
||||
onChange={(e) => isNew(item)
|
||||
? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, price: e.target.value } : l))
|
||||
: setPrice((p) => ({ ...p, [item.id]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
renderActions={(item) => (
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon-sm"
|
||||
className={item.isRemoved ? 'text-muted-foreground' : 'text-destructive hover:text-destructive'}
|
||||
aria-label={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||
title={item.isRemoved ? 'Restore line' : 'Delete line'}
|
||||
onClick={() => isNew(item)
|
||||
? setAdded((a) => a.filter((l) => l.key !== addedKey(item)))
|
||||
: setRemoved((r) => ({ ...r, [item.id]: !r[item.id] }))}
|
||||
>
|
||||
<HugeiconsIcon icon={item.isRemoved ? Undo02Icon : Delete02Icon} size={16} />
|
||||
</Button>
|
||||
)}
|
||||
footerRow={
|
||||
// Cells line up with the table columns: Item · Qty · Unit Price · Amount · action.
|
||||
<tr className="bg-neutral-50 print:hidden" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}>
|
||||
<td className="px-2 py-3">
|
||||
<Select value={pickItemId} onValueChange={(v) => pickItem(v ?? '')}>
|
||||
<SelectTrigger className="h-8 w-full"><SelectValue placeholder="Add an item…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableItems.length === 0
|
||||
? <div className="px-3 py-2 text-sm text-muted-foreground">No more items to add</div>
|
||||
: availableItems.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name} <span className="text-muted-foreground">· {c.sku}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Input
|
||||
type="number" min={1} aria-label="Quantity" className="ml-auto h-8 w-20 text-right"
|
||||
value={pickQty} onChange={(e) => setPickQty(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Input
|
||||
type="number" min={0} step="0.01" aria-label="Unit price" placeholder="Price"
|
||||
className="ml-auto h-8 w-28 text-right"
|
||||
value={pickPrice} onChange={(e) => setPickPrice(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right text-sm text-neutral-500">
|
||||
{pickItemId ? ((Number(pickQty) || 0) * (Number(pickPrice) || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 }) : ''}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-right">
|
||||
<Button
|
||||
type="button" size="icon-sm" aria-label="Add item" title="Add item (Enter)"
|
||||
onClick={addItem} disabled={!pickItemId}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[210mm] flex-wrap items-center justify-between gap-3">
|
||||
<div className="w-64">
|
||||
<Select value={userId} onValueChange={(v) => setUserId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Edited by (temporary)..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersList.map((u) => <SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty ? `Unsaved: ${changeSummary.join(', ')}` : 'No changes yet'}
|
||||
</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())}>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,19 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
||||
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||
import type { InvoiceResponse, InvoiceStatus, InvoiceVersionResponse, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||
|
||||
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
||||
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
|
||||
|
||||
const STAGE_LABEL: Record<InvoiceVersionResponse['stage'], string> = {
|
||||
Created: 'Created',
|
||||
DraftEdited: 'Draft edited',
|
||||
Printed: 'Printed',
|
||||
Finalized: 'Finalized',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
||||
Draft: 'secondary',
|
||||
Printed: 'outline',
|
||||
@@ -61,10 +68,12 @@ export default function InvoiceViewPage() {
|
||||
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
||||
const [actionOpen, setActionOpen] = useState(false)
|
||||
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
|
||||
const [versions, setVersions] = useState<InvoiceVersionResponse[]>([])
|
||||
|
||||
const fetchInvoice = useCallback(async () => {
|
||||
const inv = await invoicesApi.get(params.id)
|
||||
const [inv, vers] = await Promise.all([invoicesApi.get(params.id), invoicesApi.versions(params.id)])
|
||||
setInvoice(inv)
|
||||
setVersions(vers)
|
||||
return inv
|
||||
}, [params.id])
|
||||
|
||||
@@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status === 'Draft' && (
|
||||
<Button type="button" variant="outline" onClick={() => router.push(`/sales/invoices/${invoice.id}/edit`)}>
|
||||
Edit Items
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'Draft' && (
|
||||
<Button type="button" onClick={handlePrint}>
|
||||
Print Invoice
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status !== 'Draft' && (
|
||||
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${invoice.id}/print`, '_blank')}>
|
||||
Reprint
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'Printed' && (
|
||||
<Button type="button" onClick={handleFinalize}>
|
||||
Finalize Invoice
|
||||
@@ -167,6 +186,84 @@ export default function InvoiceViewPage() {
|
||||
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||
<p className="text-sm text-muted-foreground">Ordered quantity is the original PO; quantity is what is billed now.</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<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-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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">No items.</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoice.items.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.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>
|
||||
<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>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Version history</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quantities after each change, so you can compare the original, printed and finalized copies.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="w-16 px-3 py-2 text-left font-medium">Version</th>
|
||||
<th className="w-32 px-3 py-2 text-left font-medium">Stage</th>
|
||||
<th className="w-40 px-3 py-2 text-left font-medium">When</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Quantities</th>
|
||||
<th className="w-32 px-3 py-2 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((v) => (
|
||||
<tr key={v.id} className="border-t align-top">
|
||||
<td className="px-3 py-2 font-mono">v{v.versionNumber}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline">{STAGE_LABEL[v.stage]}</Badge></td>
|
||||
<td className="px-3 py-2">{fmtDateTime(v.createdAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{v.items.map((i) => (i.isRemoved ? `${i.itemName} (removed)` : `${i.itemName} × ${i.quantity}`)).join(', ') || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-medium">{fmtAmount(v.totalAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
||||
|
||||
Reference in New Issue
Block a user