fix invoice action dialog and order view with editing and printing capabilities
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
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 type { InvoiceResponse, UserResponse } from '@/types/api'
|
||||
|
||||
@@ -39,13 +40,17 @@ interface InvoiceActionDialogProps {
|
||||
}
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
|
||||
const [usersList, setUsersList] = useState<UserResponse[]>([])
|
||||
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>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -55,6 +60,18 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
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()
|
||||
.then((res) => setUsersList(res))
|
||||
.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
|
||||
|
||||
const lines = full?.items ?? []
|
||||
|
||||
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 {
|
||||
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
|
||||
else await invoicesApi.finalize(invoice!.id, values.userId)
|
||||
if (mode === 'print') {
|
||||
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' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
printTab?.close()
|
||||
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
|
||||
@@ -106,6 +137,45 @@ export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }:
|
||||
/>
|
||||
</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>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
|
||||
|
||||
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
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',
|
||||
})
|
||||
: '—'
|
||||
import { InvoiceTemplate } from '@/components/invoice/invoice-template'
|
||||
import { useInvoiceDocument } from '@/components/invoice/use-invoice-document'
|
||||
import type { InvoiceResponse } from '@/types/api'
|
||||
|
||||
interface OrderViewSheetProps {
|
||||
open: boolean
|
||||
@@ -37,36 +17,34 @@ interface OrderViewSheetProps {
|
||||
invoice: InvoiceResponse | null
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Previews the invoice as the printed document. The list rows carry no line items, so the full invoice is fetched.
|
||||
export function OrderViewSheet({ open, onClose, invoice }: OrderViewSheetProps) {
|
||||
const doc = useInvoiceDocument(invoice?.id, open)
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DialogTitle>Invoice Details</DialogTitle>
|
||||
<DialogTitle>Invoice Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Row label="Invoice ID" value={<span className="font-mono">{truncId(invoice.id)}</span>} />
|
||||
<Row label="Shop PO" value={<span className="font-mono">{truncId(invoice.shopPOId)}</span>} />
|
||||
<Row label="Store" value={invoice.storeName} />
|
||||
<Row label="Status" value={<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>} />
|
||||
<Row label="Payment" value={<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>} />
|
||||
<Row label="Total Amount" value={fmtAmount(invoice.totalAmount)} />
|
||||
<Row label="Printed At" value={fmtDateTime(invoice.printedAt)} />
|
||||
<Row label="Finalized At" value={fmtDateTime(invoice.finalizedAt)} />
|
||||
<Row label="Created At" value={fmtDateTime(invoice.createdAt)} />
|
||||
</div>
|
||||
{doc.invoice ? (
|
||||
<InvoiceTemplate invoice={doc.invoice} payments={doc.payments} customer={doc.customer} />
|
||||
) : (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{doc.error ? `Failed to load invoice: ${doc.error}` : 'Loading invoice…'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{doc.invoice && doc.invoice.status !== 'Draft' && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => window.open(`/invoices/${doc.invoice!.id}/print`, '_blank')}>
|
||||
Open print view
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { DataTable } 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() {
|
||||
const router = useRouter()
|
||||
const [data, setData] = useState<InvoiceResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
@@ -76,6 +78,10 @@ export function OrdersClient() {
|
||||
setViewOpen(true)
|
||||
}
|
||||
|
||||
function handleEdit(row: InvoiceResponse) {
|
||||
router.push(`/sales/invoices/${row.id}/edit`)
|
||||
}
|
||||
|
||||
function handlePrint(row: InvoiceResponse) {
|
||||
setSelectedInvoice(row)
|
||||
setActionMode('print')
|
||||
@@ -156,12 +162,24 @@ export function OrdersClient() {
|
||||
label: 'View invoice',
|
||||
onClick: handleView,
|
||||
},
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit draft items',
|
||||
onClick: handleEdit,
|
||||
visible: (target) => target.status === 'Draft',
|
||||
},
|
||||
{
|
||||
action: 'print',
|
||||
label: 'Mark as printed',
|
||||
label: 'Print invoice',
|
||||
onClick: handlePrint,
|
||||
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',
|
||||
label: 'Finalize invoice',
|
||||
|
||||
Reference in New Issue
Block a user