Fix invoice editing

This commit is contained in:
2026-10-21 12:49:02 +05:30
parent bd8b2d8b44
commit 9e39981c9a
2 changed files with 441 additions and 2 deletions
@@ -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>