fix invoice action dialog and order view with editing and printing capabilities

This commit is contained in:
2026-10-21 12:51:02 +05:30
parent 9e39981c9a
commit f2d5c9f50a
3 changed files with 117 additions and 51 deletions
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { Input } from '@/components/ui/input'
import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { invoices as invoicesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { InvoiceResponse, UserResponse } from '@/types/api' import type { InvoiceResponse, UserResponse } from '@/types/api'
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
} }
const COPY = { const COPY = {
print: { title: 'Mark Invoice as Printed', label: 'Printed By', action: 'Mark as Printed', success: 'Invoice marked as printed' }, print: { title: 'Print Invoice', label: 'Printed By', action: 'Print Invoice', success: 'Invoice printed' },
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' }, finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
} }
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) { export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([]) const [usersList, setUsersList] = useState<UserResponse[]>([])
const copy = COPY[mode] const copy = COPY[mode]
const [qty, setQty] = useState<Record<string, string>>({})
const [removed, setRemoved] = useState<Record<string, boolean>>({})
// Rows from the list endpoint carry no line items, so the full invoice is loaded for the finalize table.
const [full, setFull] = useState<InvoiceResponse | null>(null)
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
form.reset({ userId: '' }) form.reset({ userId: '' })
setFull(null)
setQty({})
setRemoved({})
if (invoice) {
invoicesApi.get(invoice.id)
.then((inv) => {
setFull(inv)
setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)])))
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' }))
}
usersApi.list() usersApi.list()
.then((res) => setUsersList(res)) .then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })) .catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
@@ -62,21 +79,35 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
if (!invoice) return null if (!invoice) return null
const lines = full?.items ?? []
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
// Opened synchronously, while the click still counts as a user gesture, so the browser allows the new tab.
const printTab = mode === 'print' ? window.open('', '_blank') : null
try { try {
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId) if (mode === 'print') {
else await invoicesApi.finalize(invoice!.id, values.userId) await invoicesApi.print(invoice!.id, values.userId)
if (printTab) printTab.location.href = `/invoices/${invoice!.id}/print`
} else {
if (!full) throw new Error('Invoice is still loading')
// Only lines the agent changed on the printed copy are sent as adjustments.
const adjustments = lines
.filter((i) => Number(qty[i.id]) !== i.quantity || !!removed[i.id] !== i.isRemoved)
.map((i) => ({ invoiceItemId: i.id, adjustedQuantity: Number(qty[i.id]), isRemoved: !!removed[i.id] }))
await invoicesApi.finalize(invoice!.id, values.userId, adjustments)
}
toast.add({ title: copy.success, type: 'success' }) toast.add({ title: copy.success, type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
} catch (err) { } catch (err) {
printTab?.close()
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' }) toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
} }
} }
return ( return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}> <Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{copy.title}</DialogTitle> <DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription> <DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
/> />
</FieldGroup> </FieldGroup>
{mode === 'finalize' && lines.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Edits from the printed copy</p>
<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-20 px-3 py-2 text-right font-medium">Printed</th>
<th className="w-24 px-3 py-2 text-right font-medium">Final qty</th>
<th className="w-24 px-3 py-2" />
</tr>
</thead>
<tbody>
{lines.map((i) => (
<tr key={i.id} className={`border-t ${removed[i.id] ? "text-muted-foreground line-through" : ""}`}>
<td className="px-3 py-2">{i.itemName}</td>
<td className="px-3 py-2 text-right">{i.quantity}</td>
<td className="px-3 py-2">
<Input
type="number" min={0} max={i.orderedQuantity ?? undefined} disabled={!!removed[i.id]}
className="h-8 text-right"
value={qty[i.id] ?? ''}
onChange={(e) => setQty((q) => ({ ...q, [i.id]: e.target.value }))}
/>
</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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>
@@ -1,35 +1,15 @@
'use client' 'use client'
import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api' import { InvoiceTemplate } from '@/components/invoice/invoice-template'
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = { import type { InvoiceResponse } from '@/types/api'
Draft: 'secondary',
Printed: 'secondary',
Finalized: 'default',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyPaid: 'secondary',
Paid: 'default',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
interface OrderViewSheetProps { interface OrderViewSheetProps {
open: boolean open: boolean
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
invoice: InvoiceResponse | null invoice: InvoiceResponse | null
} }
function Row({ label, value }: { label: string; value: React.ReactNode }) { // Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) { export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
const doc = useInvoiceDocument(invoice?.id, open)
if (!invoice) return null if (!invoice) return null
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Invoice Details</DialogTitle> <DialogTitle>Invoice Preview</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col"> {doc.invoice ? (
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} /> <InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} /> ) : (
<Row label="Store" value={invoice.storeName} /> <p className="py-10 text-center text-sm text-muted-foreground">
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} /> {doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} /> </p>
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} /> )}
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} /> {doc.invoice && doc.invoice.status !== 'Draft' && (
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} /> <div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
Open print view
</Button>
</div> </div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { DataTable } from '@/components/shared/data-table' import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table' import type { TableConfig } from '@/components/shared/data-table'
@@ -35,6 +36,7 @@ const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'd
} }
export function OrdersClient() { export function OrdersClient() {
const router = useRouter()
const [data, setData] = useState<InvoiceResponse[]>([]) const [data, setData] = useState<InvoiceResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -76,6 +78,10 @@ export function OrdersClient() {
setViewOpen(true) setViewOpen(true)
} }
function handleEdit(row: InvoiceResponse) {
router.push(`/sales/invoices/${row.id}/edit`)
}
function handlePrint(row: InvoiceResponse) { function handlePrint(row: InvoiceResponse) {
setSelectedInvoice(row) setSelectedInvoice(row)
setActionMode('print') setActionMode('print')
@@ -156,12 +162,24 @@ export function OrdersClient() {
label: 'View invoice', label: 'View invoice',
onClick: handleView, onClick: handleView,
}, },
{
action: 'edit',
label: 'Edit draft items',
onClick: handleEdit,
visible: (target) => target.status === 'Draft',
},
{ {
action: 'print', action: 'print',
label: 'Mark as printed', label: 'Print invoice',
onClick: handlePrint, onClick: handlePrint,
visible: (target) => target.status === 'Draft', visible: (target) => target.status === 'Draft',
}, },
{
action: 'print',
label: 'Reprint invoice',
onClick: (target) => window.open(`/invoices/${target.id}/print`, '_blank'),
visible: (target) => target.status !== 'Draft',
},
{ {
action: 'finalize', action: 'finalize',
label: 'Finalize invoice', label: 'Finalize invoice',