This commit is contained in:
2026-04-23 14:38:41 +03:00
parent f17dd1486c
commit c0f78c6e18
90 changed files with 10819 additions and 1583 deletions
@@ -0,0 +1,50 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
type BillActionsProps = {
billId: string
}
export function BillActions({ billId }: BillActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/purchase/bill/${billId}/edit`)
}
const handleDelete = async () => {
await api.bills.destroy(billId)
router.push("/purchase/bill")
}
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>
)
}
@@ -0,0 +1,75 @@
"use client"
import { createContext, useContext } from "react"
export type BillVendor = {
id?: number | null
first_name?: string | null
last_name?: string | null
company_name?: string | null
name?: string | null
}
export type BillResponse = {
id?: number | null
title?: string | null
job_card_id?: number | null
vendor_id?: number | null
vendor_address_id?: number | null
purchase_order_id?: number | null
bill_number?: string | null
bill_date?: string | null
bill_due_date?: string | null
payment_terms_id?: number | null
department_id?: number | null
notes?: string | null
status?: string | null
discount_type?: string | null
tax_id?: number | null
sub_total?: number | null
tax_amount?: number | null
total?: number | null
payments_made?: number | null
balance_due?: number | null
discount_amount_major?: number | null
created_at?: string | null
updated_at?: string | null
vendor?: BillVendor | null
vendor_address?: {
id?: number | null
address?: string | null
country?: { id?: number; name?: string } | null
state?: { id?: number; name?: string } | null
} | null
department?: { id?: number | null; name?: string | null } | null
job_card?: { id?: number | null; order_number?: string | null; estimate_number?: string | null } | null
purchase_order?: { id?: number | null; order_number?: string | null } | null
tax?: { id?: number | null; name?: string | null; rate?: string | null } | null
payment_terms?: { id?: number | null; name?: string | null } | null
labels?: { id?: number; title?: string; color_code?: string }[]
parts?: any[]
services?: any[]
expenses?: any[]
}
type BillContextValue = BillResponse
const BillContext = createContext<BillContextValue | null>(null)
export function BillProvider({
bill,
children,
}: {
bill: BillContextValue
children: React.ReactNode
}) {
return (
<BillContext.Provider value={bill}>
{children}
</BillContext.Provider>
)
}
export function useBill() {
return useContext(BillContext)
}
@@ -0,0 +1,80 @@
"use client"
import { Receipt } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
type BillExpense = {
id: number
bill_id: number
expense_id: number
quantity: string | number
rate: string | number
description?: string
expense?: { id?: number; title?: string; invoice_number?: string }
}
type BillExpensesSectionProps = {
expenses?: BillExpense[]
}
export function BillExpensesSection({ expenses = [] }: BillExpensesSectionProps) {
if (!expenses || expenses.length === 0) return null
const subtotal = expenses.reduce((sum, expense) => {
const qty = parseFloat(String(expense.quantity))
const rate = parseFloat(String(expense.rate))
return sum + (qty * rate)
}, 0)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="size-4" />
Expenses
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Expense</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expenses.map((expense) => {
const qty = parseFloat(String(expense.quantity))
const rate = parseFloat(String(expense.rate))
const amount = qty * rate
return (
<TableRow key={expense.id}>
<TableCell className="font-medium">
{expense.expense?.title || `Expense #${expense.expense_id}`}
</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground">
{expense.description || "—"}
</TableCell>
<TableCell className="text-right">{formatNumber(qty)}</TableCell>
<TableCell className="text-right">{formatCurrency(rate)}</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(amount)}</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={4} className="text-right">Subtotal</TableCell>
<TableCell className="text-right">{formatCurrency(subtotal)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,97 @@
"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 { BillFormValues } from "./bill.schema"
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 BillFormSummary() {
const { watch } = useFormContext<BillFormValues>()
const partItems = watch("part_items") ?? []
const serviceItems = watch("service_items") ?? []
const expenseItems = watch("expense_items") ?? []
const discountType = watch("discount")
const discountAmount = watch("discount_amount")
const taxRelation = watch("tax")
const taxRate = parseTaxRateFromLabel(taxRelation?.label)
const taxLabel = taxRelation?.label ?? "Tax"
const lineItems: DocumentLineItem[] = [
...partItems.map((p) => ({
quantity: p.quantity,
rate: p.rate,
discount_amount: p.discount_amount,
})),
...serviceItems.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,
})),
]
const groupLabels: Record<string, number> = {}
if (partItems.length > 0) {
groupLabels[`Parts (${partItems.length})`] = partItems.reduce(
(s, p) => s + (Number(p.quantity) || 0) * (Number(p.rate) || 0),
0,
)
}
if (serviceItems.length > 0) {
groupLabels[`Services (${serviceItems.length})`] = serviceItems.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>
)
}
+203 -106
View File
@@ -5,24 +5,39 @@ 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,
RhfAsyncSelectField,
RhfSelectField,
RhfTextField,
RhfTextareaField,
RhfAutoGenerateField,
RhfDateField,
} from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import { getTodayDate, toId, toRelation } from "@/shared/lib/utils"
import { useAuthApi } from "@/shared/useApi"
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, PURCHASE_ORDER_ROUTES, VENDOR_ROUTES } from "@garage/api"
import {
BillStatus,
DiscountType,
BILL_ROUTES,
DEPARTMENT_ROUTES,
JOB_CARD_ROUTES,
PAYMENT_TERM_ROUTES,
PURCHASE_ORDER_ROUTES,
VENDOR_ROUTES,
TAX_ROUTES,
} from "@garage/api"
import { toast } from "sonner"
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 { useFormContext } from "react-hook-form"
import { billFormSchema, type BillFormValues } from "./bill.schema"
import { BillFormSummary } from "./bill-form-summary"
export type BillFormProps = {
resourceId?: string | null
@@ -31,16 +46,20 @@ export type BillFormProps = {
}
const DEFAULT_VALUES: BillFormValues = {
title: "",
vendor: null,
vendor_address: null,
purchase_order: null,
job_card: null,
payment_term: null,
department: null,
title: "",
tax: null,
bill_number: "",
bill_date: "",
bill_date: getTodayDate(),
bill_due_date: "",
status: "draft",
discount: "no",
discount_amount: undefined,
notes: "",
part_items: [],
service_items: [],
@@ -49,12 +68,17 @@ const DEFAULT_VALUES: BillFormValues = {
const STATUS_OPTIONS = BillStatus.map((value) => ({
value,
label: value.replaceAll("_", " "),
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
value,
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
}))
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
label: item.name ?? item.title ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
@@ -63,36 +87,43 @@ function mapToFormValues(data: unknown): BillFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
purchase_order: toRelation(d.purchase_order_id, d.purchase_order_number ?? d.purchase_order_title),
job_card: toRelation(d.job_card_id, d.job_card_number ?? d.job_card_name),
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
department: toRelation(d.department_id, d.department_name),
title: d.title || "",
vendor: toRelation(d.vendor_id, d.vendor?.name ?? d.vendor_name),
vendor_address: toRelation(d.vendor_address_id, d.vendor_address?.address),
purchase_order: toRelation(d.purchase_order_id, d.purchase_order?.order_number ?? d.purchase_order_number),
job_card: toRelation(d.job_card_id, d.job_card?.order_number ?? d.job_card_number),
payment_term: toRelation(d.payment_terms_id, d.payment_terms?.name ?? d.payment_terms_name),
department: toRelation(d.department_id, d.department?.name ?? d.department_name),
tax: toRelation(d.tax_id, d.tax?.name ? `${d.tax.name} (${d.tax.rate}%)` : d.tax_title),
bill_number: d.bill_number || "",
bill_date: d.bill_date || "",
bill_due_date: d.bill_due_date || "",
bill_date: d.bill_date ? d.bill_date.split("T")[0] : "",
bill_due_date: d.bill_due_date ? d.bill_due_date.split("T")[0] : "",
status: d.status || "draft",
discount: d.discount_type || "no",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
notes: d.notes || "",
part_items: (d.parts ?? []).map((p: any) => ({
part_id: p.part_id ?? p.id,
title: p.part?.title ?? p.title ?? "",
title: p.part?.name ?? p.part_name ?? 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 ?? "",
})),
service_items: (d.services ?? []).map((s: any) => ({
service_id: s.service_id ?? s.id,
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
title: s.service?.name ?? s.service_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.expenses ?? []).map((e: any) => ({
expense_id: e.expense_id ?? e.id,
title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
title: e.expense?.title ?? e.expense_title ?? 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 ?? "",
})),
}
@@ -102,36 +133,61 @@ function mapFormToPayload(values: BillFormValues) {
return {
title: values.title,
vendor_id: toId(values.vendor),
vendor_address_id: toId(values.vendor_address),
purchase_order_id: toId(values.purchase_order),
job_card_id: toId(values.job_card),
payment_terms_id: toId(values.payment_term),
department_id: toId(values.department),
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
bill_number: values.bill_number || undefined,
bill_date: values.bill_date || undefined,
bill_due_date: values.bill_due_date || undefined,
status: values.status || undefined,
discount_type: values.discount || undefined,
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
notes: values.notes || undefined,
part_items: (values.part_items ?? []).map((item) => ({
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,
})),
service_items: (values.service_items ?? []).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,
})),
expense_items: (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,
})),
}
}
// ── Transaction-level discount field (conditional) ──
function TransactionDiscountField() {
const { watch } = useFormContext<BillFormValues>()
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 BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
const api = useAuthApi()
@@ -140,22 +196,24 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [BILL_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const discount = form.watch("discount")
const isLineItemDiscount = discount === "line_item_level"
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: BillFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
const promise = (isEditing && resourceId
? api.bills.update(resourceId, payload)
: api.bills.create(payload)
: api.bills.create(payload)) as Promise<any>
toast.promise(promise, {
loading: isEditing ? "Updating bill..." : "Creating bill...",
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
error: isEditing ? "Failed to update bill" : "Failed to create bill",
})
return promise
},
onSuccess: () => {
@@ -169,102 +227,141 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>{isEditing ? "Failed to update bill" : "Failed to create bill"}</AlertTitle>
<AlertTitle>
{isEditing ? "Failed to update bill" : "Failed to create bill"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter bill title" 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">
<RhfTextField name="bill_number" label="Bill Number" placeholder="e.g. BILL-001" />
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
{/* ── Main column (9/12) ── */}
<div className="lg:col-span-9">
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAutoGenerateField autoFetch table="bills" name="bill_number" label="Bill Number" placeholder="e.g. BILL-001" />
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
</div>
<TransactionDiscountField />
<PartsSelectorField<BillFormValues, "part_items"> name="part_items" showDiscount={isLineItemDiscount} />
<ServicesSelectorField<BillFormValues, "service_items"> name="service_items" showDiscount={isLineItemDiscount} />
<ExpenseItemsSelectorField<BillFormValues, "expense_items"> name="expense_items" showDiscount={isLineItemDiscount} />
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Bill" : "Create Bill")}
</Button>
</FieldGroup>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="bill_date" label="Bill Date" type="date" />
<RhfTextField name="bill_due_date" label="Due Date" type="date" />
{/* ── Sidebar column (3/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="bill_date" label="Bill Date" />
<RhfDateField name="bill_due_date" label="Due Date" />
</div>
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<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_term"
label="Payment Terms"
placeholder="Select payment terms"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number || item.estimate_number || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="purchase_order"
label="Purchase Order"
placeholder="Select purchase order"
queryKey={[PURCHASE_ORDER_ROUTES.INDEX]}
listFn={() => api.purchaseOrders.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number || item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
</FieldGroup>
</CardContent>
</Card>
<div className="mt-4">
<BillFormSummary />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<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="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="payment_term"
label="Payment Term"
placeholder="Select payment term"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="purchase_order"
label="Purchase Order"
placeholder="Select purchase order"
queryKey={[PURCHASE_ORDER_ROUTES.INDEX]}
listFn={() => api.purchaseOrders.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number || item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfTextareaField name="notes" label="Notes" rows={3} />
<PartsSelectorField<BillFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<BillFormValues, "service_items"> name="service_items" />
<ExpenseItemsSelectorField<BillFormValues, "expense_items"> name="expense_items" />
<Button type="submit" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Bill
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Bill
</>
)}
</Button>
</FieldGroup>
</div>
</Rhform>
)
}
@@ -0,0 +1,180 @@
"use client"
import {
FileText,
Calendar,
Hash,
Building2,
AlertTriangle,
CheckCircle2,
TimerIcon,
ShoppingCart,
CreditCard,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { cn } from "@/shared/lib/utils"
import { formatDate, formatCurrency, formatEnum } from "@/shared/utils/formatters"
import { getFullName } from "@/shared/utils/getFullName"
import { useBill } from "./bill-context"
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
const statusVariantMap: Record<string, "secondary" | "default" | "destructive" | "outline"> = {
draft: "secondary",
open: "default",
un_paid: "destructive",
partially_paid: "secondary",
paid: "default",
}
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"
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 BillGeneralInfo() {
const bill = useBill()
if (!bill) return null
const vendor = bill.vendor || {}
const department = bill.department || null
const jobCard = bill.job_card || null
const purchaseOrder = bill.purchase_order || null
const dueInfo = getDueInfo(bill.bill_due_date as string | undefined, bill.status as string | undefined)
return (
<div className="grid gap-6">
{/* ── Summary Hero ── */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* 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">
{bill.status === "paid" && <CheckCircle2 className="size-4 text-green-500" />}
{bill.status === "un_paid" && <AlertTriangle className="size-4 text-destructive" />}
{(bill.status === "draft" || bill.status === "open") && <TimerIcon className="size-4 text-muted-foreground" />}
<Badge variant={statusVariantMap[String(bill.status ?? "")] ?? "outline"} className="text-sm px-2 py-0.5">
{formatEnum(String(bill.status ?? ""))}
</Badge>
</div>
{bill.bill_number && (
<span className="mt-1 text-xs text-muted-foreground">{bill.bill_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(bill.bill_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>
{/* Vendor */}
<Card className="flex flex-col gap-1 p-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Vendor</span>
<span className="mt-1 text-lg font-semibold">{vendor.company_name || getFullName(vendor) || "—"}</span>
</Card>
</div>
{/* ── Bill Details ── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" />
Bill Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<InfoItem icon={Hash} label="Bill Number" value={bill.bill_number} />
<InfoItem icon={Calendar} label="Bill Date" value={formatDate(bill.bill_date)} />
<InfoItem icon={Calendar} label="Due Date" value={formatDate(bill.bill_due_date)} />
<InfoItem icon={Building2} label="Department" value={department?.name} />
<InfoItem icon={CreditCard} label="Payment Terms" value={bill.payment_terms?.name} />
{jobCard?.order_number && (
<InfoItem icon={Hash} label="Job Card" value={jobCard.order_number} />
)}
{purchaseOrder?.order_number && (
<InfoItem icon={ShoppingCart} label="Purchase Order" value={purchaseOrder.order_number} />
)}
{bill.tax?.name && (
<InfoItem icon={Hash} label="Tax" value={`${bill.tax.name} (${bill.tax.rate}%)`} />
)}
</div>
</CardContent>
</Card>
{/* ── Notes ── */}
{bill.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{bill.notes}</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,80 @@
"use client"
import { Wrench } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
type BillPart = {
id: number
bill_id: number
part_id: number
quantity: string | number
rate: string | number
description?: string
part?: { id?: number; name?: string; part_number?: string }
}
type BillPartsSectionProps = {
parts?: BillPart[]
}
export function BillPartsSection({ parts = [] }: BillPartsSectionProps) {
if (!parts || parts.length === 0) return null
const subtotal = parts.reduce((sum, part) => {
const qty = parseFloat(String(part.quantity))
const rate = parseFloat(String(part.rate))
return sum + (qty * rate)
}, 0)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="size-4" />
Parts
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parts.map((part) => {
const qty = parseFloat(String(part.quantity))
const rate = parseFloat(String(part.rate))
const amount = qty * rate
return (
<TableRow key={part.id}>
<TableCell className="font-medium">
{part.part?.name || `Part #${part.part_id}`}
</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground">
{part.description || "—"}
</TableCell>
<TableCell className="text-right">{formatNumber(qty)}</TableCell>
<TableCell className="text-right">{formatCurrency(rate)}</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(amount)}</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={4} className="text-right">Subtotal</TableCell>
<TableCell className="text-right">{formatCurrency(subtotal)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,157 @@
"use client"
import { useRouter } from "next/navigation"
import {
BadgeDollarSignIcon,
CalendarIcon,
CreditCardIcon,
HashIcon,
UserIcon,
} from "lucide-react"
import { CrudResource } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { Card, CardContent } from "@/shared/components/ui/card"
import { PAYMENT_MADE_ROUTES } from "@garage/api"
import { PaymentMadeForm } from "@/modules/payment-mades/payment-made-form"
import { useBill } from "./bill-context"
import { formatDate } from "@/shared/utils/formatters"
import { getFullName } from "@/shared/utils/getFullName"
import { toRelation } from "@/shared/lib/utils"
export function BillPaymentsSection() {
const bill = useBill()
const router = useRouter()
return (
<CrudResource<any>
extraParams={{ bill_id: bill?.id }}
routeKey={PAYMENT_MADE_ROUTES.INDEX}
getClient={(api) => ({
list: (query?: any) => api.paymentMades.list(query),
destroy: (id: string) => api.paymentMades.destroy(id),
})}
tableHeader={({ invalidateQuery }) => (
<Card className="mb-4">
<CardContent className="flex items-center justify-between">
<h2 className="text-base font-semibold">Payments Made</h2>
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentMadeForm
initialData={{
anount: bill?.balance_due,
vendor: toRelation(bill?.vendor?.id, getFullName(bill?.vendor as any)),
payment_made: bill?.balance_due != null ? String(bill.balance_due) : undefined,
}}
billId={bill?.id}
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: "vendor",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
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.vendor?.name || item.vendor_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_made",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original as any
const amount = item.payment_made != null
? Number(item.payment_made).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?.name || item.payment_mode_name || "—"}
</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original as any
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatDate(item.payment_date) || "—"}</span>
</div>
)
},
},
{
accessorKey: "notes",
header: () => <span>Notes</span>,
enableSorting: false,
cell: ({ row }) => {
const item = row.original as any
const notes = item.notes
if (!notes) return <span className="text-muted-foreground">—</span>
return (
<span
className="max-w-50 truncate block text-muted-foreground"
title={notes}
>
{notes}
</span>
)
},
},
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,80 @@
"use client"
import { Briefcase } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatCurrency, formatNumber } from "@/shared/utils/formatters"
type BillService = {
id: number
bill_id: number
service_id: number
quantity: string | number
rate: string | number
description?: string
service?: { id?: number; name?: string; price?: string }
}
type BillServicesSectionProps = {
services?: BillService[]
}
export function BillServicesSection({ services = [] }: BillServicesSectionProps) {
if (!services || services.length === 0) return null
const subtotal = services.reduce((sum, service) => {
const qty = parseFloat(String(service.quantity))
const rate = parseFloat(String(service.rate))
return sum + (qty * rate)
}, 0)
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-4" />
Services
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Quantity</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((service) => {
const qty = parseFloat(String(service.quantity))
const rate = parseFloat(String(service.rate))
const amount = qty * rate
return (
<TableRow key={service.id}>
<TableCell className="font-medium">
{service.service?.name || `Service #${service.service_id}`}
</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground">
{service.description || "—"}
</TableCell>
<TableCell className="text-right">{formatNumber(qty)}</TableCell>
<TableCell className="text-right">{formatCurrency(rate)}</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(amount)}</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={4} className="text-right">Subtotal</TableCell>
<TableCell className="text-right">{formatCurrency(subtotal)}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,85 @@
"use client"
import { BillStatus } from "@garage/api"
import { Badge, badgeVariants } from "@/shared/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { confirm } from "@/shared/components/confirm-dialog"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useState } from "react"
import { formatEnum } from "@/shared/utils/formatters"
const STATUS_TRIGGER_CLASS_NAMES: Record<string, string> = {
draft: badgeVariants({ variant: "outline" }),
open: badgeVariants({ variant: "secondary" }),
un_paid: badgeVariants({ variant: "destructive" }),
partially_paid: badgeVariants({ variant: "default" }),
paid: badgeVariants({ variant: "default" }),
}
function isBillStatus(value: unknown): value is BillStatus {
return typeof value === "string" && BillStatus.includes(value as BillStatus)
}
type BillStatusBadgeProps = {
bill: {
id: string
status: string | null | undefined
}
}
export default function BillStatusBadge({ bill }: BillStatusBadgeProps) {
const api = useAuthApi()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const { id: billId, status } = bill
if (!isBillStatus(status)) return null
const handleStatusChange = async (nextStatus: string) => {
if (!isBillStatus(nextStatus)) return
const confirmed = await confirm({
title: "Update Bill Status",
description: `Change bill status to ${formatEnum(nextStatus)}?`,
})
if (!confirmed) return
try {
setIsLoading(true)
await api.bills.update(billId, { status: nextStatus })
toast.success("Bill status updated")
router.refresh()
} catch (error) {
toast.error("Failed to update bill status")
} finally {
setIsLoading(false)
}
}
return (
<Select value={status} onValueChange={handleStatusChange} disabled={isLoading}>
<SelectTrigger
className={`border-0 size-auto p-0 h-auto font-medium ${STATUS_TRIGGER_CLASS_NAMES[status]}`}
>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{BillStatus.map((s) => (
<SelectItem key={s} value={s}>
{formatEnum(s)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,117 @@
"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 { useBill } from "./bill-context"
export function BillTotalsSummary() {
const bill = useBill()
if (!bill) return null
const parts = bill.parts ?? []
const services = bill.services ?? []
const expenses = bill.expenses ?? []
const hasItems = parts.length > 0 || services.length > 0 || expenses.length > 0
if (!hasItems) return null
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)
}
const partsTotal = lineTotal(parts)
const servicesTotal = lineTotal(services)
const expensesTotal = lineTotal(expenses)
// Use API-computed values when available, fall back to manual calc
const subTotal = bill.sub_total ?? (partsTotal + servicesTotal + expensesTotal)
const taxAmount = bill.tax_amount ?? 0
const discountAmount = bill.discount_amount_major ?? 0
const total = bill.total ?? subTotal
const paymentsMade = bill.payments_made ?? 0
const balanceDue = bill.balance_due ?? total
const discount = bill.discount_type
return (
<Card>
<CardContent className="pt-6">
<div className="ml-auto max-w-sm space-y-2">
{parts.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Parts ({parts.length})</span>
<span>{formatCurrency(partsTotal)}</span>
</div>
)}
{services.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Services ({services.length})</span>
<span>{formatCurrency(servicesTotal)}</span>
</div>
)}
{expenses.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Expenses ({expenses.length})</span>
<span>{formatCurrency(expensesTotal)}</span>
</div>
)}
<Separator />
<div className="flex justify-between text-sm font-medium">
<span>Subtotal</span>
<span>{formatCurrency(subTotal)}</span>
</div>
{discountAmount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Discount{discount && discount !== "no" ? ` (${formatEnum(discount)})` : ""}</span>
<span className="text-muted-foreground">−{formatCurrency(discountAmount)}</span>
</div>
)}
{taxAmount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tax{bill.tax?.name ? ` (${bill.tax.name})` : ""}</span>
<span>{formatCurrency(taxAmount)}</span>
</div>
)}
<Separator />
<div className={cn(
"flex justify-between rounded-lg px-3 py-2 text-base font-bold bg-muted/50",
)}>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
{paymentsMade > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Payments Made</span>
<span className="text-emerald-600">−{formatCurrency(paymentsMade)}</span>
</div>
)}
{paymentsMade > 0 && (
<>
<Separator />
<div className={cn(
"flex justify-between rounded-lg px-3 py-2 text-base font-semibold",
balanceDue > 0 ? "text-destructive bg-destructive/5" : "text-emerald-700 bg-emerald-50 dark:bg-emerald-950/20",
)}>
<span>Balance Due</span>
<span>{formatCurrency(balanceDue)}</span>
</div>
</>
)}
</div>
</CardContent>
</Card>
)
}
+15 -1
View File
@@ -9,6 +9,7 @@ const billPartItemSchema = 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 billServiceItemSchema = 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,21 +27,33 @@ const billExpenseItemSchema = 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(),
})
const billFormSchema = z.object({
// ── Required ──
title: z.string().min(1, "Title is required"),
// ── Relations ──
vendor: relationFieldSchema,
vendor_address: relationFieldSchema,
purchase_order: relationFieldSchema,
job_card: relationFieldSchema,
payment_term: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
tax: relationFieldSchema,
// ── Optional fields ──
bill_number: z.string().optional(),
bill_date: z.string().optional(),
bill_due_date: z.string().optional(),
status: z.string().optional(),
discount: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
notes: z.string().optional(),
// ── Line items ──
part_items: z.array(billPartItemSchema).optional(),
service_items: z.array(billServiceItemSchema).optional(),
expense_items: z.array(billExpenseItemSchema).optional(),