updates
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { InvoiceEditForm } from "./invoice-edit-form"
|
||||
import { useInvoice } from "./invoice-context"
|
||||
|
||||
type InvoiceActionsProps = {
|
||||
invoiceId: string
|
||||
@@ -18,9 +22,11 @@ type InvoiceActionsProps = {
|
||||
export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/invoice/${invoiceId}/edit`)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const invoice = useInvoice()
|
||||
const handleEditSuccess = () => {
|
||||
setIsEditOpen(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -29,22 +35,39 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <DropdownMenuItem onClick={() => setIsEditOpen(true)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* <Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Invoice</DialogTitle>
|
||||
</DialogHeader>
|
||||
<InvoiceEditForm
|
||||
initialData={{
|
||||
|
||||
}}
|
||||
resourceId={invoiceId}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { ApiResponse } from "@garage/api"
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type InvoiceContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
id: number | string
|
||||
subject?: string | null
|
||||
invoice_number?: string | null
|
||||
discount?: string | null
|
||||
sub_total?: number | string | null
|
||||
total?: number | string | null
|
||||
payments_recieved?: number | string | null
|
||||
received_payment?: number | string | null
|
||||
balance_due?: number | string | null
|
||||
amount?: number | string | null
|
||||
invoice_parts?: { quantity?: string | number; rate?: string | number }[]
|
||||
invoice_services?: { quantity?: string | number; rate?: string | number }[]
|
||||
invoice_expenses?: { quantity?: string | number; rate?: string | number }[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const InvoiceContext = createContext<InvoiceContextValue | null>(null)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toId } from "@/shared/lib/utils"
|
||||
import { InvoiceStatus, InvoiceDiscount, INVOICE_ROUTES } from "@garage/api"
|
||||
import { z } from "zod"
|
||||
|
||||
// ── Schema for edit form (simplified, edit only) ──
|
||||
|
||||
const invoiceEditFormSchema = z.object({
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
due_date: z.string().optional(),
|
||||
invoice_title: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
terms_and_conditions: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
discount: z.string().optional(),
|
||||
})
|
||||
|
||||
type InvoiceEditFormValues = z.infer<typeof invoiceEditFormSchema>
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const STATUS_OPTIONS = InvoiceStatus.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1).replace(/_/g, " "),
|
||||
}))
|
||||
|
||||
const DISCOUNT_OPTIONS = InvoiceDiscount.map((v) => ({
|
||||
value: v,
|
||||
label: v.charAt(0).toUpperCase() + v.slice(1).replace(/_/g, " "),
|
||||
}))
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: InvoiceEditFormValues = {
|
||||
subject: "",
|
||||
due_date: "",
|
||||
invoice_title: "",
|
||||
notes: "",
|
||||
terms_and_conditions: "",
|
||||
status: "",
|
||||
discount: "no",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): InvoiceEditFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
return {
|
||||
subject: d.subject || "",
|
||||
due_date: d.due_date || "",
|
||||
invoice_title: d.invoice_title || "",
|
||||
notes: d.notes || "",
|
||||
terms_and_conditions: d.terms_and_conditions || "",
|
||||
status: d.status || "",
|
||||
discount: d.discount || "no",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: InvoiceEditFormValues) {
|
||||
return {
|
||||
subject: values.subject,
|
||||
due_date: values.due_date || undefined,
|
||||
invoice_title: values.invoice_title || undefined,
|
||||
notes: values.notes || undefined,
|
||||
terms_and_conditions: values.terms_and_conditions || undefined,
|
||||
status: values.status || undefined,
|
||||
discount: values.discount || "no",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type InvoiceEditFormProps = {
|
||||
resourceId: string
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InvoiceEditForm({ resourceId, initialData, onSuccess }: InvoiceEditFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<InvoiceEditFormValues, any>({
|
||||
schema: invoiceEditFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: InvoiceEditFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = api.invoices.update(resourceId, payload as any)
|
||||
toast.promise(promise, {
|
||||
loading: "Updating invoice...",
|
||||
success: "Invoice updated successfully",
|
||||
error: "Failed to update invoice",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to update invoice</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="subject"
|
||||
label="Subject"
|
||||
placeholder="Invoice subject"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfDateField
|
||||
name="due_date"
|
||||
label="Due Date"
|
||||
/>
|
||||
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextField
|
||||
name="invoice_title"
|
||||
label="Invoice Title"
|
||||
placeholder="e.g., Tax Invoice, Proforma Invoice"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="discount"
|
||||
label="Discount Type"
|
||||
placeholder="Select discount type"
|
||||
options={DISCOUNT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextareaField
|
||||
name="notes"
|
||||
label="Notes"
|
||||
rows={3}
|
||||
placeholder="Add any notes..."
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="terms_and_conditions"
|
||||
label="Terms & Conditions"
|
||||
rows={3}
|
||||
placeholder="Add terms and conditions..."
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
<Save className="size-4" />
|
||||
{isPending ? "Updating..." : "Update Invoice"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
useDocumentTotals,
|
||||
type DocumentLineItem,
|
||||
} from "@/shared/hooks/use-document-totals"
|
||||
import { DocumentTotalsSummary } from "@/shared/components/document-totals-summary"
|
||||
|
||||
import type { InvoiceFormValues } from "./invoice.schema"
|
||||
|
||||
/**
|
||||
* Parse the numeric rate from an option label formatted as "Title (15%)".
|
||||
* Returns undefined when no match.
|
||||
*/
|
||||
function parseTaxRateFromLabel(label: string | undefined | null): number | undefined {
|
||||
if (!label) return undefined
|
||||
const match = label.match(/\((\d+(?:\.\d+)?)%\)/)
|
||||
return match ? Number(match[1]) : undefined
|
||||
}
|
||||
|
||||
export function InvoiceFormSummary() {
|
||||
const { watch } = useFormContext<InvoiceFormValues>()
|
||||
|
||||
const parts = watch("parts") ?? []
|
||||
const services = watch("services") ?? []
|
||||
const expenseItems = watch("expense_items") ?? []
|
||||
const discountType = watch("discount")
|
||||
const discountAmount = watch("discount_amount")
|
||||
const taxRelation = watch("tax")
|
||||
|
||||
// Rate is embedded in the label: "VAT 15% (15%)" → parseTaxRateFromLabel → 15
|
||||
const taxRate = parseTaxRateFromLabel(taxRelation?.label)
|
||||
const taxLabel = taxRelation?.label ?? "Tax"
|
||||
|
||||
// Flatten all line items into the generic shape
|
||||
const lineItems: DocumentLineItem[] = [
|
||||
...parts.map((p) => ({
|
||||
quantity: p.quantity,
|
||||
rate: p.rate,
|
||||
discount_amount: p.discount_amount,
|
||||
})),
|
||||
...services.map((s) => ({
|
||||
quantity: s.quantity,
|
||||
rate: s.rate,
|
||||
discount_amount: s.discount_amount,
|
||||
})),
|
||||
...expenseItems.map((e) => ({
|
||||
quantity: e.quantity,
|
||||
rate: e.rate,
|
||||
discount_amount: e.discount_amount,
|
||||
})),
|
||||
]
|
||||
|
||||
// Group breakdowns for display (only when more than one group has items)
|
||||
const groupLabels: Record<string, number> = {}
|
||||
if (parts.length > 0) {
|
||||
groupLabels[`Parts (${parts.length})`] = parts.reduce(
|
||||
(s, p) => s + (Number(p.quantity) || 0) * (Number(p.rate) || 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (services.length > 0) {
|
||||
groupLabels[`Services (${services.length})`] = services.reduce(
|
||||
(s, sv) => s + (Number(sv.quantity) || 0) * (Number(sv.rate) || 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (expenseItems.length > 0) {
|
||||
groupLabels[`Expenses (${expenseItems.length})`] = expenseItems.reduce(
|
||||
(s, e) => s + (Number(e.quantity) || 0) * (Number(e.rate) || 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
const totals = useDocumentTotals({
|
||||
lineItems,
|
||||
discountType,
|
||||
discountAmount,
|
||||
taxRate,
|
||||
})
|
||||
|
||||
if (!totals.hasLineItems) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DocumentTotalsSummary
|
||||
totals={totals}
|
||||
discountType={discountType}
|
||||
taxLabel={taxLabel}
|
||||
groupLabels={Object.keys(groupLabels).length > 1 ? groupLabels : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
@@ -29,19 +30,19 @@ import {
|
||||
import {
|
||||
INVOICE_ROUTES,
|
||||
DEPARTMENT_ROUTES,
|
||||
ESTIMATE_ROUTES,
|
||||
PAYMENT_TERM_ROUTES,
|
||||
INVOICE_SEQUENCE_ROUTES,
|
||||
PAYMENT_MODE_ROUTES,
|
||||
CUSTOMER_ROUTES,
|
||||
TAX_ROUTES,
|
||||
InvoiceStatus,
|
||||
InvoiceDiscount,
|
||||
} from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { InvoiceSequenceCrudDialog } from "./invoice-sequence-crud-dialog"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
|
||||
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
|
||||
import { InvoiceFormSummary } from "./invoice-form-summary"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
@@ -78,12 +79,14 @@ const DEFAULT_VALUES: InvoiceFormValues = {
|
||||
invoice_to: null,
|
||||
invoice_number: "",
|
||||
invoice_title: "",
|
||||
invoice_date: "",
|
||||
invoice_date: new Date().toISOString().split("T")[0],
|
||||
due_date: "",
|
||||
status: "draft",
|
||||
kms_in: undefined,
|
||||
has_insurance: false,
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
tax: null,
|
||||
deposit_to: "",
|
||||
notes: "",
|
||||
terms_and_conditions: "",
|
||||
@@ -116,6 +119,8 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
kms_in: d.kms_in ? Number(d.kms_in) : undefined,
|
||||
has_insurance: d.has_insurance ?? false,
|
||||
discount: d.discount || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
tax: toRelation(d.tax_id, d.tax_title ?? d.tax?.title),
|
||||
deposit_to: d.deposit_to || "",
|
||||
notes: d.notes || "",
|
||||
terms_and_conditions: d.terms_and_conditions || "",
|
||||
@@ -124,6 +129,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
services: (d.invoice_services ?? d.services ?? []).map((s: any) => ({
|
||||
@@ -131,6 +137,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
|
||||
quantity: Number(s.quantity) || 1,
|
||||
rate: Number(s.rate) || 0,
|
||||
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
|
||||
description: s.description ?? "",
|
||||
})),
|
||||
expense_items: (d.invoice_expenses ?? d.expenses ?? []).map((e: any) => ({
|
||||
@@ -138,6 +145,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
|
||||
quantity: Number(e.quantity) || 1,
|
||||
rate: Number(e.rate) || 0,
|
||||
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
|
||||
description: e.description ?? "",
|
||||
})),
|
||||
}
|
||||
@@ -163,6 +171,8 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
kms_in: values.kms_in || undefined,
|
||||
has_insurance: values.has_insurance,
|
||||
discount: values.discount || undefined,
|
||||
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
||||
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
|
||||
deposit_to: values.deposit_to || undefined,
|
||||
notes: values.notes || undefined,
|
||||
terms_and_conditions: values.terms_and_conditions || undefined,
|
||||
@@ -170,18 +180,21 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
services: (values.services ?? []).map((item) => ({
|
||||
service_id: item.service_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
expenses: (values.expense_items ?? []).map((item) => ({
|
||||
expense_id: item.expense_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
}
|
||||
@@ -213,6 +226,25 @@ function InsurerField() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Transaction-level discount amount field ──
|
||||
|
||||
function TransactionDiscountField() {
|
||||
const { watch, register } = useFormContext<InvoiceFormValues>()
|
||||
const discount = watch("discount")
|
||||
|
||||
if (discount !== "transaction_level") return null
|
||||
|
||||
return (
|
||||
<RhfTextField
|
||||
|
||||
name="discount_amount"
|
||||
label="Discount Amount"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) {
|
||||
@@ -227,6 +259,9 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const discount = form.watch("discount")
|
||||
const isLineItemDiscount = discount === "line_item_level"
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: InvoiceFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
@@ -258,116 +293,121 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required />
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
|
||||
<RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
|
||||
{/* ── Main column (8/12) ── */}
|
||||
<div className="lg:col-span-9">
|
||||
<FieldGroup>
|
||||
<RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAutoGenerateField autoFetch table="invoices" name="invoice_number" label="Invoice Number" placeholder="INV-0001" />
|
||||
<RhfTextField name="invoice_title" label="Invoice Title" placeholder="e.g. Tax Invoice" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
|
||||
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
<TransactionDiscountField />
|
||||
|
||||
<PartsSelectorField<InvoiceFormValues, "parts"> name="parts" showDiscount={isLineItemDiscount} />
|
||||
<ServicesSelectorField<InvoiceFormValues, "services"> name="services" showDiscount={isLineItemDiscount} />
|
||||
<ExpenseItemsSelectorField<InvoiceFormValues, "expense_items"> name="expense_items" showDiscount={isLineItemDiscount} />
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
||||
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Invoice" : "Create Invoice")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
|
||||
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} />
|
||||
{/* ── Sidebar column (4/12) ── */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<RhfDateField name="invoice_date" label="Invoice Date" />
|
||||
<RhfDateField name="due_date" label="Due Date" />
|
||||
</div>
|
||||
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="tax"
|
||||
label="Tax"
|
||||
placeholder="Select tax rate"
|
||||
queryKey={[TAX_ROUTES.INDEX]}
|
||||
listFn={() => api.taxes.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="payment_terms"
|
||||
label="Payment Terms"
|
||||
placeholder="Select payment terms"
|
||||
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Invoice Sequence</span>
|
||||
<InvoiceSequenceCrudDialog />
|
||||
</div>
|
||||
<RhfAsyncSelectField
|
||||
name="invoice_sequence"
|
||||
label=""
|
||||
placeholder="Select sequence"
|
||||
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
|
||||
listFn={() => api.invoiceSequences.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
||||
<InsurerField />
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<InvoiceFormSummary />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfDateField name="invoice_date" label="Invoice Date" />
|
||||
<RhfDateField name="due_date" label="Due Date" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "invoice_to">
|
||||
name="invoice_to"
|
||||
label="Invoice To"
|
||||
placeholder="Select billing contact..."
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="estimate"
|
||||
label="Estimate"
|
||||
placeholder="Select estimate"
|
||||
queryKey={[ESTIMATE_ROUTES.INDEX]}
|
||||
listFn={() => api.estimates.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title||item.estimate_number || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_terms"
|
||||
label="Payment Terms"
|
||||
placeholder="Select payment terms"
|
||||
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="invoice_sequence"
|
||||
label="Invoice Sequence"
|
||||
placeholder="Select sequence"
|
||||
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
|
||||
listFn={() => api.invoiceSequences.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="kms_in" label="KMs In" type="number" placeholder="e.g. 50000" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentModes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="deposit_to" label="Deposit To" placeholder="e.g. Main Account" />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
||||
<InsurerField />
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
||||
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
|
||||
|
||||
<PartsSelectorField<InvoiceFormValues, "parts"> name="parts" />
|
||||
<ServicesSelectorField<InvoiceFormValues, "services"> name="services" />
|
||||
<ExpenseItemsSelectorField<InvoiceFormValues, "expense_items"> name="expense_items" />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Invoice" : "Create Invoice")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
@@ -5,11 +6,12 @@ import {
|
||||
Users,
|
||||
Car,
|
||||
Building2,
|
||||
CircleDollarSign,
|
||||
Clock,
|
||||
Mail,
|
||||
Phone,
|
||||
DollarSign,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
TimerIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
@@ -19,45 +21,9 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { formatDate, formatCurrency, formatEnum, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoiceData = {
|
||||
id?: number
|
||||
subject?: string
|
||||
invoice_number?: string
|
||||
invoice_title?: string
|
||||
invoice_date?: string
|
||||
due_date?: string
|
||||
status?: string
|
||||
notes?: string
|
||||
terms_and_conditions?: string
|
||||
customer_name?: string
|
||||
customer_id?: number
|
||||
customer?: any
|
||||
vehicle_name?: string
|
||||
vehicle_id?: number
|
||||
vehicle?: any
|
||||
department_name?: string
|
||||
department_id?: number
|
||||
payment_terms_id?: number
|
||||
payment_mode_id?: number
|
||||
amount?: number | string | null
|
||||
received_payment?: number | string | null
|
||||
discount?: string
|
||||
has_insurance?: number | boolean
|
||||
insurer_id?: number | null
|
||||
insurer?: any
|
||||
kms_in?: number | null
|
||||
invoice_to_id?: number | null
|
||||
billing_address_id?: number | null
|
||||
delivery_address_id?: number | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type InvoiceGeneralInfoProps = {
|
||||
invoice: InvoiceData
|
||||
}
|
||||
import { useInvoice } from "./invoice-context"
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
@@ -83,7 +49,7 @@ function InfoItem({
|
||||
)
|
||||
}
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
const statusVariantMap: Record<string, "secondary" | "default" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
paid: "default",
|
||||
@@ -91,56 +57,120 @@ const statusColorMap: Record<string, string> = {
|
||||
void: "outline",
|
||||
}
|
||||
|
||||
export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
function getDueInfo(dueDateStr?: string, status?: string) {
|
||||
if (!dueDateStr) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueDateStr)
|
||||
const diffMs = due.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
const isPaid = status === "paid" || status === "void"
|
||||
if (isPaid) return { label: formatDate(dueDateStr), variant: "neutral" as const }
|
||||
if (diffDays < 0) return { label: `${Math.abs(diffDays)} days overdue`, variant: "overdue" as const }
|
||||
if (diffDays === 0) return { label: "Due today", variant: "today" as const }
|
||||
if (diffDays <= 7) return { label: `Due in ${diffDays} day${diffDays === 1 ? "" : "s"}`, variant: "soon" as const }
|
||||
return { label: formatDate(dueDateStr), variant: "neutral" as const }
|
||||
}
|
||||
|
||||
export function InvoiceGeneralInfo() {
|
||||
const _invoice = useInvoice()
|
||||
if (!_invoice) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoice = _invoice as any
|
||||
|
||||
const customer = invoice.customer || {}
|
||||
const vehicle = invoice.vehicle || {}
|
||||
const insurer = invoice.insurer || {}
|
||||
const department = invoice.department || null
|
||||
|
||||
const total = parseFloat(String(invoice.total ?? 0)) || 0
|
||||
const paid = parseFloat(String(invoice.payments_recieved ?? invoice.received_payment ?? 0)) || 0
|
||||
const balanceDue = parseFloat(String(invoice.balance_due ?? 0)) || 0
|
||||
|
||||
const dueInfo = getDueInfo(invoice.due_date as string | undefined, invoice.status as string | undefined)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Invoice Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{invoice.subject && (
|
||||
<Badge variant="secondary">{invoice.subject}</Badge>
|
||||
)}
|
||||
{invoice.status && (
|
||||
<Badge variant={statusColorMap[invoice.status] as any ?? "outline"}>
|
||||
{formatEnum(invoice.status)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Invoice Number"
|
||||
value={invoice.invoice_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Invoice Date"
|
||||
value={formatDate(invoice.invoice_date)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Due Date"
|
||||
value={formatDate(invoice.due_date)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customer & Vehicle Information */}
|
||||
{/* ── Summary Hero ── */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Status */}
|
||||
<Card className="flex flex-col gap-1 p-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Status</span>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{invoice.status === "paid" && <CheckCircle2 className="size-4 text-green-500" />}
|
||||
{invoice.status === "overdue" && <AlertTriangle className="size-4 text-destructive" />}
|
||||
{(invoice.status === "draft" || invoice.status === "open") && <TimerIcon className="size-4 text-muted-foreground" />}
|
||||
<Badge variant={statusVariantMap[String(invoice.status ?? "")] ?? "outline"} className="text-sm px-2 py-0.5">
|
||||
{formatEnum(String(invoice.status ?? ""))}
|
||||
</Badge>
|
||||
</div>
|
||||
{invoice.invoice_number && (
|
||||
<span className="mt-1 text-xs text-muted-foreground">{invoice.invoice_number}</span>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Due Date */}
|
||||
<Card className={cn(
|
||||
"flex flex-col gap-1 p-4",
|
||||
dueInfo?.variant === "overdue" && "border-destructive/60 bg-destructive/5",
|
||||
dueInfo?.variant === "today" && "border-orange-400/60 bg-orange-50 dark:bg-orange-950/20",
|
||||
dueInfo?.variant === "soon" && "border-yellow-400/60 bg-yellow-50 dark:bg-yellow-950/20",
|
||||
)}>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Due Date</span>
|
||||
<span className={cn(
|
||||
"mt-1 text-lg font-semibold",
|
||||
dueInfo?.variant === "overdue" && "text-destructive",
|
||||
dueInfo?.variant === "today" && "text-orange-600",
|
||||
dueInfo?.variant === "soon" && "text-yellow-700 dark:text-yellow-500",
|
||||
)}>
|
||||
{formatDate(invoice.due_date) || "—"}
|
||||
</span>
|
||||
{dueInfo && dueInfo.variant !== "neutral" && (
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
dueInfo.variant === "overdue" && "text-destructive",
|
||||
dueInfo.variant === "today" && "text-orange-600",
|
||||
dueInfo.variant === "soon" && "text-yellow-700 dark:text-yellow-500",
|
||||
)}>
|
||||
{dueInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Total Amount */}
|
||||
<Card className="flex flex-col gap-1 p-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Total Amount</span>
|
||||
<span className="mt-1 text-lg font-semibold">{formatCurrency(total)}</span>
|
||||
{paid > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{formatCurrency(paid)} received</span>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Balance Due */}
|
||||
<Card className={cn(
|
||||
"flex flex-col gap-1 p-4",
|
||||
balanceDue > 0 && invoice.status !== "paid" && "border-primary/40 bg-primary/5",
|
||||
balanceDue <= 0 && "border-green-500/40 bg-green-50 dark:bg-green-950/20",
|
||||
)}>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Balance Due</span>
|
||||
<span className={cn(
|
||||
"mt-1 text-lg font-bold",
|
||||
balanceDue > 0 && invoice.status !== "paid" && "text-primary",
|
||||
balanceDue <= 0 && "text-green-600",
|
||||
)}>
|
||||
{formatCurrency(balanceDue)}
|
||||
</span>
|
||||
{balanceDue <= 0 && (
|
||||
<span className="text-xs font-medium text-green-600">Fully paid</span>
|
||||
)}
|
||||
{invoice.discount && invoice.discount !== "no" && (
|
||||
<span className="text-xs text-muted-foreground">Discount: {formatEnum(invoice.discount)}</span>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Customer & Vehicle ── */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Customer Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -155,21 +185,9 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
label="Customer Name"
|
||||
value={customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : invoice.customer_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={customer.email}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Phone"
|
||||
value={customer.phone}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Alternate Phone"
|
||||
value={customer.alternate_phone}
|
||||
/>
|
||||
<InfoItem icon={Mail} label="Email" value={customer.email} />
|
||||
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
|
||||
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
|
||||
</div>
|
||||
{customer.address_line_1 && (
|
||||
<>
|
||||
@@ -180,8 +198,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
{customer.address_line_1}
|
||||
{customer.address_line_2 ? `, ${customer.address_line_2}` : ""}
|
||||
<br />
|
||||
{customer.city ? `${customer.city}` : ""}
|
||||
{customer.zip_code ? `, ${customer.zip_code}` : ""}
|
||||
{customer.city ?? ""}{customer.zip_code ? `, ${customer.zip_code}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -189,7 +206,6 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vehicle Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -204,107 +220,49 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
label="Vehicle"
|
||||
value={vehicle.make && vehicle.model ? `${vehicle.make} ${vehicle.model}` : invoice.vehicle_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="License Plate"
|
||||
value={vehicle.license_plate}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="VIN"
|
||||
value={vehicle.vin_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Engine Number"
|
||||
value={vehicle.engine_number}
|
||||
/>
|
||||
<InfoItem icon={Hash} label="License Plate" value={vehicle.license_plate} />
|
||||
<InfoItem icon={Hash} label="VIN" value={vehicle.vin_number} />
|
||||
<InfoItem icon={Hash} label="Engine Number" value={vehicle.engine_number} />
|
||||
</div>
|
||||
{vehicle.mileage && (
|
||||
<>
|
||||
<Separator />
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Mileage"
|
||||
value={formatNumber(vehicle.mileage)}
|
||||
/>
|
||||
<InfoItem icon={Clock} label="Mileage" value={formatNumber(vehicle.mileage)} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payment & Insurance Information */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Payment Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="size-4" />
|
||||
Payment Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Amount"
|
||||
value={invoice.amount ? formatCurrency(invoice.amount) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Received Payment"
|
||||
value={invoice.received_payment ? formatCurrency(invoice.received_payment) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Discount"
|
||||
value={invoice.discount}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Invoice Meta ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<InfoItem icon={Hash} label="Invoice Number" value={invoice.invoice_number} />
|
||||
<InfoItem icon={Calendar} label="Invoice Date" value={formatDate(invoice.invoice_date)} />
|
||||
<InfoItem icon={Calendar} label="Due Date" value={formatDate(invoice.due_date)} />
|
||||
<InfoItem icon={Building2} label="Department" value={department?.name} />
|
||||
<InfoItem icon={Hash} label="Has Insurance" value={invoice.has_insurance ? "Yes" : "No"} />
|
||||
{invoice.has_insurance && insurer.id && (
|
||||
<InfoItem icon={Users} label="Insurer" value={insurer.name} />
|
||||
)}
|
||||
{invoice.kms_in ? (
|
||||
<InfoItem icon={Clock} label="KMs In" value={formatNumber(invoice.kms_in)} />
|
||||
) : null}
|
||||
{invoice.invoice_title ? (
|
||||
<InfoItem icon={FileText} label="Invoice Title" value={invoice.invoice_title} />
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Insurance & Additional Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Additional Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={invoice.department_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Has Insurance"
|
||||
value={invoice.has_insurance ? "Yes" : "No"}
|
||||
/>
|
||||
{invoice.has_insurance && insurer.id && (
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Insurer"
|
||||
value={insurer.name}
|
||||
/>
|
||||
)}
|
||||
{invoice.kms_in && (
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="KMs In"
|
||||
value={formatNumber(invoice.kms_in)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
{/* ── Notes & Terms ── */}
|
||||
{(invoice.notes || invoice.terms_and_conditions) && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{invoice.notes && (
|
||||
@@ -313,7 +271,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
<CardTitle className="text-base">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.notes}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -323,13 +281,12 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
<CardTitle className="text-base">Terms & Conditions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.terms_and_conditions}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{invoice.terms_and_conditions}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { INVOICE_ROUTES, type InvoiceShowData } from "@garage/api"
|
||||
import { toRelation } from "@/shared/lib/utils"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
|
||||
|
||||
type InvoicePaymentButtonProps = {
|
||||
invoiceId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
function toTodayString() {
|
||||
return new Date().toISOString().split("T")[0]!
|
||||
}
|
||||
|
||||
export function InvoicePaymentButton({ invoiceId, onSuccess }: InvoicePaymentButtonProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [INVOICE_ROUTES.BY_ID, invoiceId],
|
||||
queryFn: () => api.invoices.show(invoiceId),
|
||||
})
|
||||
|
||||
const invoice = data?.data
|
||||
|
||||
const total = parseFloat(String(invoice?.amount ?? 0)) || 0
|
||||
const paid = parseFloat(String(invoice?.received_payment ?? 0)) || 0
|
||||
const balanceDue = Math.max(0, total - paid)
|
||||
|
||||
const customer = invoice?.customer || {}
|
||||
const customerLabel = customer.first_name
|
||||
? `${customer.first_name} ${customer.last_name ?? ""}`.trim()
|
||||
: invoice?.customer_name ?? undefined
|
||||
|
||||
const prefilledInitialData = {
|
||||
...(invoice?.customer_id
|
||||
? { customer: toRelation(invoice?.customer_id, customerLabel) }
|
||||
: {}),
|
||||
amount_received: balanceDue > 0 ? String(balanceDue) : "",
|
||||
payment_date: toTodayString(),
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog title="Record Payment" paramKey="invoice-payment">
|
||||
{(resourceId, { close }) => (
|
||||
<PaymentReceivedForm
|
||||
resourceId={resourceId}
|
||||
initialData={prefilledInitialData}
|
||||
invoiceId={invoiceId}
|
||||
onSuccess={() => {
|
||||
close()
|
||||
onSuccess?.()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
import { CrudResource } from '@/shared/data-view/resource-page'
|
||||
import { ColumnHeader } from '@/shared/data-view/table-view'
|
||||
import { BadgeDollarSignIcon, CalendarIcon, ClipboardListIcon, CreditCardIcon, HashIcon, UserIcon } from 'lucide-react'
|
||||
import { PAYMENT_RECEIVED_ROUTES } from '@garage/api'
|
||||
import FormDialog from '@/shared/components/form-dialog'
|
||||
import { PaymentReceivedForm } from '@/modules/payment-received/payment-received-form'
|
||||
import { useInvoice } from './invoice-context'
|
||||
import { Card, CardContent } from '@/shared/components/ui/card'
|
||||
import { useRouter } from 'next/navigation'
|
||||
export default function InvoicePaymentsSection() {
|
||||
const invoice = useInvoice()
|
||||
const router = useRouter()
|
||||
|
||||
console.log("InvoicePaymentsSection invoice:", invoice)
|
||||
return (
|
||||
<div>
|
||||
<CrudResource<any>
|
||||
extraParams={{ invoice_id: invoice?.id }}
|
||||
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
|
||||
getClient={(api) => ({
|
||||
list: (query?: any) => api.paymentReceived.list(query),
|
||||
destroy: (id: string) => api.paymentReceived.destroy(id),
|
||||
})}
|
||||
tableHeader={({ invalidateQuery }) =>
|
||||
<Card className='mb-4'>
|
||||
<CardContent className='flex justify-between '>
|
||||
<h2>Payments</h2>
|
||||
<FormDialog title="Record Payment">
|
||||
{(resourceId) => (
|
||||
|
||||
<PaymentReceivedForm
|
||||
invoiceId={invoice?.id as string}
|
||||
invoiceCustomer={invoice?.customer as any}
|
||||
invoiceAmount={invoice?.balance_due as any}
|
||||
resourceId={resourceId}
|
||||
onSuccess={()=>{router.refresh(); invalidateQuery()}}
|
||||
/>
|
||||
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
columns={({ actionsColumn }) => [
|
||||
{
|
||||
accessorKey: "payment_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<HashIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{item.payment_number || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customer_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{item.customer_name || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "job_card_name",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
const label = item.job_card_number || item.job_card_name
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{label || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "amount_received",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
const amount = item.amount_received
|
||||
? Number(item.amount_received).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: "—"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
|
||||
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
|
||||
{amount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_mode",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="capitalize">{item.payment_mode?.title || "—"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
const formatted = item.payment_date
|
||||
? new Date(item.payment_date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "—"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{formatted}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "note",
|
||||
header: () => <span>Note</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original as any
|
||||
const note = item.note
|
||||
if (!note) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<span className="max-w-50 truncate block" title={note}>
|
||||
{note}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
// actionsColumn(),
|
||||
]}
|
||||
></CrudResource>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { CrudDialog } from "@/shared/components/crud-dialog"
|
||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { INVOICE_SEQUENCE_ROUTES } from "@garage/api"
|
||||
import { InvoiceSequenceForm } from "./invoice-sequence-form"
|
||||
|
||||
export function InvoiceSequenceCrudDialog() {
|
||||
const api = useAuthApi()
|
||||
|
||||
return (
|
||||
<CrudDialog
|
||||
title="Invoice Sequences"
|
||||
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
|
||||
getClient={() => api.invoiceSequences}
|
||||
resourceLabel="invoice sequence"
|
||||
columns={() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "sequence_title",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Sequence" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "prefix",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Prefix" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "start_number",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Start #" />,
|
||||
},
|
||||
]}
|
||||
renderForm={({ resourceId, initialData, onSuccess }) => (
|
||||
<InvoiceSequenceForm
|
||||
resourceId={resourceId}
|
||||
initialData={initialData}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextField, RhfAsyncSelectField, RhfCheckboxField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useEffect } from "react"
|
||||
import { DEPARTMENT_ROUTES } from "@garage/api"
|
||||
|
||||
const invoiceSequenceSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
sequence_title: z.string().optional(),
|
||||
prefix: z.string().optional(),
|
||||
start_number: z.coerce.number().int().min(1).optional(),
|
||||
auto_generate: z.boolean().optional(),
|
||||
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
|
||||
})
|
||||
|
||||
type InvoiceSequenceFormValues = z.infer<typeof invoiceSequenceSchema>
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
type InvoiceSequenceFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: any
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function InvoiceSequenceForm({ resourceId, initialData, onSuccess }: InvoiceSequenceFormProps) {
|
||||
const api = useAuthApi()
|
||||
const isEditing = !!resourceId
|
||||
|
||||
const form = useForm<InvoiceSequenceFormValues>({
|
||||
resolver: zodResolver(invoiceSequenceSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
sequence_title: "",
|
||||
prefix: "",
|
||||
start_number: 1,
|
||||
auto_generate: false,
|
||||
department: null,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const d = initialData?.data ?? initialData
|
||||
form.reset({
|
||||
title: d.title ?? "",
|
||||
sequence_title: d.sequence_title ?? "",
|
||||
prefix: d.prefix ?? "",
|
||||
start_number: d.start_number ?? 1,
|
||||
auto_generate: d.auto_generate ?? false,
|
||||
department: d.department_id
|
||||
? { value: String(d.department_id), label: d.department_name ?? `#${d.department_id}` }
|
||||
: null,
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const handleSubmit = async (values: InvoiceSequenceFormValues) => {
|
||||
try {
|
||||
const payload = {
|
||||
title: values.title,
|
||||
sequence_title: values.sequence_title || undefined,
|
||||
prefix: values.prefix || undefined,
|
||||
start_number: values.start_number,
|
||||
auto_generate: values.auto_generate,
|
||||
department_id: values.department ? Number(values.department.value) : undefined,
|
||||
}
|
||||
|
||||
const promise = isEditing
|
||||
? api.invoiceSequences.update(resourceId!, payload)
|
||||
: api.invoiceSequences.create(payload)
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating..." : "Creating...",
|
||||
success: isEditing ? "Updated successfully" : "Created successfully",
|
||||
error: isEditing ? "Failed to update" : "Failed to create",
|
||||
})
|
||||
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
// toast already shown
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Default Invoice Sequence"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="sequence_title"
|
||||
label="Sequence Title"
|
||||
placeholder="e.g. INV"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="prefix"
|
||||
label="Prefix"
|
||||
placeholder="e.g. INV-"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="start_number"
|
||||
label="Start Number"
|
||||
type="number"
|
||||
placeholder="e.g. 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfCheckboxField name="auto_generate" label="Auto Generate" />
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
{form.formState.isSubmitting
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update" : "Create")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { InvoiceStatus } from "@garage/api"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { badgeVariants } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
type InvoiceStatusValue = (typeof InvoiceStatus)[number]
|
||||
|
||||
type InvoiceStatusBadgeProps = {
|
||||
invoice: {
|
||||
status?: string | null
|
||||
id: number | string
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_TRIGGER_CLASS_NAMES: Record<InvoiceStatusValue, string> = {
|
||||
draft: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-200",
|
||||
open: "border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-900 dark:bg-blue-950/70 dark:text-blue-200",
|
||||
over_due: "border-red-200 bg-red-100 text-red-700 dark:border-red-900 dark:bg-red-950/70 dark:text-red-200",
|
||||
paid: "border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/70 dark:text-emerald-200",
|
||||
partially_paid: "border-amber-200 bg-amber-100 text-amber-700 dark:border-amber-900 dark:bg-amber-950/70 dark:text-amber-200",
|
||||
un_paid: "border-orange-200 bg-orange-100 text-orange-700 dark:border-orange-900 dark:bg-orange-950/70 dark:text-orange-200",
|
||||
}
|
||||
|
||||
function isInvoiceStatus(value?: string | null): value is InvoiceStatusValue {
|
||||
return !!value && InvoiceStatus.includes(value as InvoiceStatusValue)
|
||||
}
|
||||
|
||||
export default function InvoiceStatusBadge({ invoice }: InvoiceStatusBadgeProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [status, setStatus] = useState<InvoiceStatusValue | undefined>(
|
||||
isInvoiceStatus(invoice.status) ? invoice.status : undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(isInvoiceStatus(invoice.status) ? invoice.status : undefined)
|
||||
}, [invoice.status])
|
||||
|
||||
const handleStatusChange = async (nextStatus: string) => {
|
||||
if (!isInvoiceStatus(nextStatus) || nextStatus === status || isUpdating) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentStatus = status
|
||||
const confirmed = await confirm({
|
||||
title: "Change Invoice Status",
|
||||
description: currentStatus
|
||||
? `Change invoice status from ${formatEnum(currentStatus)} to ${formatEnum(nextStatus)}?`
|
||||
: `Change invoice status to ${formatEnum(nextStatus)}?`,
|
||||
confirmLabel: "Update",
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdating(true)
|
||||
|
||||
try {
|
||||
const promise = api.invoices.update(String(invoice.id), { status: nextStatus })
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: `Updating invoice status to ${formatEnum(nextStatus)}...`,
|
||||
success: "Invoice status updated successfully.",
|
||||
error: "Failed to update invoice status.",
|
||||
})
|
||||
|
||||
await promise
|
||||
setStatus(nextStatus)
|
||||
router.refresh()
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={status} onValueChange={handleStatusChange} disabled={isUpdating}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label="Invoice status"
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline" }),
|
||||
"h-8 gap-1.5 rounded-full px-3 py-1 text-xs font-medium shadow-none focus-visible:ring-2 [&_svg]:size-3.5",
|
||||
status
|
||||
? STATUS_TRIGGER_CLASS_NAMES[status]
|
||||
: "border-border bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Set status">
|
||||
{formatEnum(status ?? null)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{InvoiceStatus.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{formatEnum(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { formatCurrency, formatEnum } from "@/shared/utils/formatters"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { useInvoice } from "./invoice-context"
|
||||
|
||||
export function InvoiceTotalsSummary() {
|
||||
const invoice = useInvoice()
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const parts = invoice.invoice_parts ?? []
|
||||
const services = invoice.invoice_services ?? []
|
||||
const expenses = invoice.invoice_expenses ?? []
|
||||
const discount = invoice.discount
|
||||
const displayTotal = parseFloat(String(invoice.total ?? 0)) || 0
|
||||
const paid = parseFloat(String(invoice.payments_recieved ?? invoice.received_payment ?? 0)) || 0
|
||||
const balanceDue = parseFloat(String(invoice.balance_due ?? 0)) || 0
|
||||
|
||||
const hasItems = parts.length > 0 || services.length > 0 || expenses.length > 0
|
||||
|
||||
if (!hasItems && displayTotal === 0) return null
|
||||
|
||||
const subTotal = parseFloat(String(invoice.sub_total ?? 0)) || 0
|
||||
|
||||
function lineTotal(items: { quantity?: string | number; rate?: string | number }[]) {
|
||||
return items.reduce((sum, item) => {
|
||||
const qty = parseFloat(String(item.quantity ?? 0))
|
||||
const rate = parseFloat(String(item.rate ?? 0))
|
||||
return sum + (isNaN(qty) || isNaN(rate) ? 0 : qty * rate)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="ml-auto max-w-sm space-y-2">
|
||||
{hasItems && (
|
||||
<>
|
||||
{parts.length > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Parts ({parts.length})</span>
|
||||
<span>{formatCurrency(lineTotal(parts))}</span>
|
||||
</div>
|
||||
)}
|
||||
{services.length > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Services ({services.length})</span>
|
||||
<span>{formatCurrency(lineTotal(services))}</span>
|
||||
</div>
|
||||
)}
|
||||
{expenses.length > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Expenses ({expenses.length})</span>
|
||||
<span>{formatCurrency(lineTotal(expenses))}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(subTotal)}</span>
|
||||
</div>
|
||||
|
||||
{discount && discount !== "no" && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Discount ({formatEnum(discount)})</span>
|
||||
<span className="text-muted-foreground">Applied</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between text-base font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(displayTotal)}</span>
|
||||
</div>
|
||||
|
||||
{paid > 0 && (
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Amount Received</span>
|
||||
<span>– {formatCurrency(paid)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={cn(
|
||||
"flex justify-between rounded-lg px-3 py-2 text-base font-bold",
|
||||
balanceDue > 0 ? "bg-primary/10 text-primary" : "bg-green-500/10 text-green-600",
|
||||
)}>
|
||||
<span>Balance Due</span>
|
||||
<span>{formatCurrency(balanceDue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const invoicePartItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -17,6 +18,7 @@ const invoiceServiceItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -25,6 +27,7 @@ const invoiceExpenseItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -52,6 +55,8 @@ const invoiceFormSchema = z.object({
|
||||
kms_in: z.coerce.number().optional(),
|
||||
has_insurance: z.boolean().default(false),
|
||||
discount: z.string().optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
tax: relationFieldSchema,
|
||||
deposit_to: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
terms_and_conditions: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user