add day-end and change item model

This commit is contained in:
Dhananjaya99
2026-08-20 14:10:39 +05:30
parent 8835ba9500
commit 4cc6a546c7
43 changed files with 8955 additions and 1680 deletions
@@ -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>
)
}