add day-end and change item model
This commit is contained in:
@@ -27,9 +27,11 @@ import {
|
||||
Inbox,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
LayoutTemplate,
|
||||
LineChart,
|
||||
ListTree,
|
||||
Lock,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
@@ -116,8 +118,9 @@ const navItems: {
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
|
||||
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
|
||||
{ title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
|
||||
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
|
||||
{ title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
|
||||
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { salesApi } from "@/lib/api/sales"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -17,14 +17,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoice | null
|
||||
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
||||
// (id/no/status/balance) is already on the summary, so a list row can open it without a
|
||||
// separate getInvoice() round trip. SalesInvoice extends SalesInvoiceSummary, so the detail
|
||||
// page's full invoice still satisfies this as-is.
|
||||
invoice: SalesInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPaid: (invoice: SalesInvoice, payment: SalesInvoicePayment) => void
|
||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
||||
* it started that way or this dialog auto-posted it first. */
|
||||
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against a posted invoice's balance — full or partial, into an
|
||||
* existing GL cash/bank account. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
|
||||
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
|
||||
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
|
||||
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
|
||||
* invoice skips straight to payment. Modeled on GrnPaymentDialog's "pick an account, submit" shape. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
@@ -64,16 +72,32 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
|
||||
onPaid(invoice, payment)
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
)
|
||||
onPaid(payment, "Posted")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
toast.error(
|
||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
||||
errorMessage(err)
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -83,10 +107,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay invoice {invoice.invoiceNo}</DialogTitle>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
</p>
|
||||
|
||||
@@ -134,7 +159,11 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record payment"}
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { SalesInvoiceStatus } from "@/types/sales"
|
||||
|
||||
// Same fixed-width convention as components/receiving/status-badges.tsx's GRN badges — every
|
||||
// pill is the same size regardless of label length ("Draft" vs "Approved" vs "Partial").
|
||||
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
|
||||
|
||||
function statusClass(status: SalesInvoiceStatus) {
|
||||
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Posted") return "bg-success/10 text-success border-transparent"
|
||||
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
|
||||
}
|
||||
|
||||
// "Posted" reads as "Approved" everywhere in the UI — the underlying SalesInvoiceStatus value
|
||||
// stays "Posted" (that's what the API sends/expects), only the displayed label changes.
|
||||
const statusLabel: Record<SalesInvoiceStatus, string> = {
|
||||
Draft: "Draft",
|
||||
Posted: "Approved",
|
||||
Cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export function SalesInvoiceStatusBadge({ status }: { status: SalesInvoiceStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${statusClass(status)}`}>
|
||||
{statusLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type SalesPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function salesPaymentStatus(paidAmount: number, balanceAmount: number): SalesPaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function paymentClass(status: SalesPaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const paymentLabel: Record<SalesPaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partially paid",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function SalesPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = salesPaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
|
||||
{paymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user