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'
|
} 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',
|
||||||
|
|||||||
Reference in New Issue
Block a user