This commit is contained in:
Mohammad Khyata 2026-04-23 14:38:41 +03:00
parent f17dd1486c
commit c0f78c6e18
90 changed files with 10819 additions and 1583 deletions

95
.github/skills/invoice-pattern/SKILL.md vendored Normal file
View File

@ -0,0 +1,95 @@
# Invoice Pattern Skill
This skill defines the standard for implementing invoice-like forms (Invoice, Estimate, Job Card, Purchase Order, Bill, etc.) in the carage-erp dashboard. All such forms must follow this pattern for layout, discount/tax handling, and summary calculation. The current Invoice form is the canonical reference.
---
## 1. Layout
- **Two-column grid:**
- **Main column (9/12):**
- Subject, invoice number/title
- Status select, Discount Type select
- Conditional Transaction Discount field
- Line item selectors: Parts, Services, Expense Items (with optional line-level discount)
- Notes, Terms & Conditions
- Submit button
- **Sidebar column (3/12):**
- Invoice date, Due date
- Customer, Vehicle selectors
- Tax select (see below)
- Department, Payment Terms, Invoice Sequence, Insurance fields
- Summary card (see below)
## 2. Discount Implementation
- **Discount Type:**
- Field: `discount` (enum: 'no', 'line_item_level', 'transaction_level')
- Select field in main column
- **Transaction-level Discount:**
- Field: `discount_amount` (number)
- Only shown when `discount === "transaction_level"`
- **Line-level Discount:**
- Each line item (parts, services, expenses) has `discount_amount` field
- Only shown when `discount === "line_item_level"`
- **Payload Mapping:**
- Only include `discount_amount` at transaction level if `discount === "transaction_level"`
- Only include per-line `discount_amount` if `discount === "line_item_level"`
## 3. Tax Type Implementation
- **Tax Field:**
- Field: `tax` (relationFieldSchema: `{ value: string, label: string } | null`)
- Uses `RhfAsyncSelectField` in sidebar
- `mapOption`: `{ value: String(item.id), label: `${item.title} (${item.rate}%)` }`
- The selected tax's rate is parsed from the label string in the summary (regex: `/\((\d+(?:\.\d+)?)%\)/`)
## 4. Summary Implementation
- **Summary Card:**
- Always rendered in the sidebar below the Details card
- Uses `InvoiceFormSummary` (form-aware adapter)
- `InvoiceFormSummary` flattens all line items, reads discount/tax fields, and passes them to `useDocumentTotals` hook
- `useDocumentTotals` (pure hook) computes subtotal, discounts, tax, and total
- `DocumentTotalsSummary` (pure display component) renders the summary
---
## Reference: Invoice Form
- See `apps/dashboard/modules/invoices/invoice-form.tsx` for the canonical implementation.
- Schema: `apps/dashboard/modules/invoices/invoice.schema.ts`
- Summary logic: `apps/dashboard/modules/invoices/invoice-form-summary.tsx`, `shared/hooks/use-document-totals.ts`, `shared/components/document-totals-summary.tsx`
---
## Required for All Invoice-like Forms
- Follow the above layout and field conventions
- Use the same discount/tax logic and summary calculation
- Use the same field and payload mapping patterns
- Use the same summary component structure
---
## Example: Tax Field (in sidebar)
```tsx
<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}
/>
```
## Example: Discount Type Select (in main column)
```tsx
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
```
## Example: Summary Card
```tsx
<div className="mt-4">
<InvoiceFormSummary />
</div>
```

View File

@ -0,0 +1,42 @@
"use client"
import { use } from "react"
import { useQuery } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { BillForm } from "@/modules/bills/bill-form"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { BILL_ROUTES } from "@garage/api"
export default function BillEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const api = useAuthApi()
const router = useRouter()
const { data, isLoading } = useQuery({
queryKey: [BILL_ROUTES.BY_ID, id],
queryFn: () => api.bills.show(id),
})
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading...
</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<div className="p-6">
<BillForm
resourceId={id}
initialData={data}
onSuccess={() => router.push(`/purchase/bill/${id}`)}
/>
</div>
</DashboardPage>
)
}

View File

@ -0,0 +1,43 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { BillActions } from '@/modules/bills/bill-actions'
import { BillProvider, type BillResponse } from '@/modules/bills/bill-context'
import BillStatusBadge from '@/modules/bills/bill-status-badge'
import { ReceiptIcon } from 'lucide-react'
import React from 'react'
export default async function BillDetailLayout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const bill = await api.bills.show(id)
const data = bill.data as BillResponse
const title = data?.title || data?.bill_number || 'Bill Details'
return (
<BillProvider bill={data}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={data?.bill_number ? `Bill #: ${data.bill_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
backHref="/purchase/bill"
actions={
<div className="flex space-x-2 items-center">
<BillStatusBadge bill={{id, status:data?.status}} />
<BillActions billId={id} />
</div>
}
tabs={[
{
href: `/purchase/bill/${id}`,
label: 'Details',
},
]}
>
{props.children}
</DashboardDetailsPage>
</BillProvider>
)
}

View File

@ -0,0 +1,32 @@
import { getServerApi } from '@garage/api/server'
import { BillGeneralInfo } from '@/modules/bills/bill-general-info'
import { BillPartsSection } from '@/modules/bills/bill-parts-section'
import { BillServicesSection } from '@/modules/bills/bill-services-section'
import { BillExpensesSection } from '@/modules/bills/bill-expenses-section'
import { BillTotalsSummary } from '@/modules/bills/bill-totals-summary'
import { BillPaymentsSection } from '@/modules/bills/bill-payments-section'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function BillDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const bill = await api.bills.show(id)
const data = (bill as any)?.data ?? bill
if (!data) {
return <div className="text-muted-foreground">Bill not found.</div>
}
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<BillGeneralInfo />
<BillPartsSection parts={data.parts} />
<BillServicesSection services={data.services} />
<BillExpensesSection expenses={data.expenses} />
<BillPaymentsSection />
<BillTotalsSummary />
</div>
</DashboardPage>
)
}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useRouter } from "next/navigation"
import FormDialog from "@/shared/components/form-dialog" import FormDialog from "@/shared/components/form-dialog"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { ResourcePage } from "@/shared/data-view/resource-page" import { ResourcePage } from "@/shared/data-view/resource-page"
@ -7,16 +8,20 @@ import { ColumnHeader } from "@/shared/data-view/table-view"
import { BillForm } from "@/modules/bills/bill-form" import { BillForm } from "@/modules/bills/bill-form"
import { BILL_ROUTES } from "@garage/api" import { BILL_ROUTES } from "@garage/api"
import type { BillsClient } from "@garage/api" import type { BillsClient } from "@garage/api"
import { formatDate } from "@/shared/utils/formatters"
export default function BillsPage() { export default function BillsPage() {
const router = useRouter()
return ( return (
<ResourcePage<BillsClient> <ResourcePage<BillsClient>
pageTitle="Bills" pageTitle="Bills"
routeKey={BILL_ROUTES.INDEX} routeKey={BILL_ROUTES.INDEX}
getClient={(api) => api.bills} getClient={(api) => api.bills}
onRowClick={(row) => router.push(`/purchase/bill/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Bill"> <FormDialog classNames={{ dialogContent: "lg:min-w-6xl" }} title="Bill">
{(resourceId) => ( {(resourceId) => (
<BillForm <BillForm
resourceId={resourceId} resourceId={resourceId}
@ -33,37 +38,45 @@ export default function BillsPage() {
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />, header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
cell: ({ row }) => (row.original as any).bill_number || "—", cell: ({ row }) => (row.original as any).bill_number || "—",
}, },
{
accessorKey: "total",
header: ({ column }) => <ColumnHeader column={column} title="Total" />,
cell: ({ row }) => (row.original as any).total || "—",
},
{
accessorKey: "balance_due",
header: ({ column }) => <ColumnHeader column={column} title="Balance Due" />,
cell: ({ row }) => (row.original as any).balance_due || "—",
},
{ {
accessorKey: "title", accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />, header: ({ column }) => <ColumnHeader column={column} title="Title" />,
}, },
{ {
accessorKey: "vendor_name", accessorKey: "vendor",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />, header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—", cell: ({ row }) => (row.original as any).vendor?.name || "—",
}, },
{ {
accessorKey: "bill_date", accessorKey: "bill_date",
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />, header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
cell: ({ row }) => { cell: ({ row }) => formatDate((row.original as any).bill_date) || "—",
const value = (row.original as any).bill_date
return value ? new Date(value).toLocaleDateString() : "—"
},
}, },
{ {
accessorKey: "bill_due_date", accessorKey: "bill_due_date",
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />, header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
cell: ({ row }) => { cell: ({ row }) => formatDate((row.original as any).bill_due_date) || "—",
const value = (row.original as any).bill_due_date
return value ? new Date(value).toLocaleDateString() : "—"
},
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />, header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => { cell: ({ row }) => {
const status = (row.original as any).status const status = (row.original as any).status
return <Badge variant={status === "paid" ? "default" : "secondary"}>{status || "—"}</Badge> return (
<Badge variant={status === "paid" ? "default" : "secondary"}>
{status?.replace(/_/g, " ") || "—"}
</Badge>
)
}, },
}, },
actionsColumn(), actionsColumn(),
@ -71,3 +84,4 @@ export default function BillsPage() {
/> />
) )
} }

View File

@ -0,0 +1,38 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { ExpenseActions } from '@/modules/expenses/expense-actions'
import { ExpenseProvider, type ExpenseContextValue } from '@/modules/expenses/expense-context'
import { ReceiptIcon } from 'lucide-react'
import React from 'react'
export default async function ExpenseDetailLayout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const expense = await api.expenses.getById(id)
const data = expense.data as ExpenseContextValue
const title = data?.title || data?.invoice_number || 'Expense Details'
return (
<ExpenseProvider expense={data}>
<DashboardDetailsPage
className="p-0 lg:p-0"
title={title}
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
icon={<ReceiptIcon className="size-5" />}
backHref="/purchase/expense"
actions={<ExpenseActions expenseId={id} />}
tabs={[
{
href: `/purchase/expense/${id}`,
label: 'Details',
},
]}
>
{props.children}
</DashboardDetailsPage>
</ExpenseProvider>
)
}

View File

@ -0,0 +1,36 @@
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import { ExpenseGeneralInfo } from '@/modules/expenses/expense-general-info'
import { ExpenseItemsSection } from '@/modules/expenses/expense-items-section'
import { ExpensePaymentsSection } from '@/modules/expenses/expense-payments-section'
import { getServerApi } from '@garage/api/server'
export default async function ExpenseDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const expense = await api.expenses.show(id)
const data = (expense as any)?.data ?? expense
if (!data) {
return <div className="text-muted-foreground">Expense not found.</div>
}
const taxLabel = data.tax?.title ? `${data.tax.title} (${data.tax.rate}%)` : undefined
return (
<DashboardPage header={null}>
<div className="grid gap-6">
<ExpenseGeneralInfo />
<ExpensePaymentsSection />
<ExpenseItemsSection
items={data.expense_items}
discountType={data.discount}
subTotal={data.sub_total}
discountAmount={data.discount_amount_major}
taxAmount={data.tax_amount}
total={data.total}
taxLabel={taxLabel}
/>
</div>
</DashboardPage>
)
}

View File

@ -7,16 +7,23 @@ import { ExpenseForm } from "@/modules/expenses/expense-form"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { EXPENSE_ROUTES } from "@garage/api" import { EXPENSE_ROUTES } from "@garage/api"
import type { ExpensesClient } from "@garage/api" import type { ExpensesClient } from "@garage/api"
import { useRouter } from "next/navigation"
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
export default function ExpensesPage() { export default function ExpensesPage() {
const router = useRouter()
return ( return (
<ResourcePage<ExpensesClient> <ResourcePage<ExpensesClient>
pageTitle="Expenses" pageTitle="Expenses"
routeKey={EXPENSE_ROUTES.INDEX} routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses} getClient={(api) => api.expenses}
onRowClick={(row)=>router.push(`/purchase/expense/${row.id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Expense"> <FormDialog
title="Expense"
classNames={{ dialogContent: "lg:min-w-6xl" }}
>
{(resourceId) => ( {(resourceId) => (
<ExpenseForm <ExpenseForm
resourceId={resourceId} resourceId={resourceId}
@ -38,38 +45,47 @@ export default function ExpensesPage() {
cell: ({ row }) => (row.original as any).invoice_number || "—", cell: ({ row }) => (row.original as any).invoice_number || "—",
}, },
{ {
accessorKey: "vendor_name", accessorKey: "vendor",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />, header: () => "Vendor",
cell: ({ row }) => (row.original as any).vendor_name || "—", cell: ({ row }) => {
const vendor = (row.original as any).vendor
return vendor?.company_name || vendor?.name || "—"
},
}, },
{ {
accessorKey: "expense_date", accessorKey: "expense_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />, header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => { cell: ({ row }) => formatDate((row.original as any).expense_date),
const val = (row.original as any).expense_date
return val ? new Date(val).toLocaleDateString() : "—"
}, },
{
accessorKey: "total",
header: () => "Total",
cell: ({ row }) => formatCurrency((row.original as any).total ?? 0),
},
{
accessorKey: "balance_due",
header: () => "Balance Due",
cell: ({ row }) => formatCurrency((row.original as any).balance_due ?? 0),
}, },
{ {
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />, header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => { cell: ({ row }) => {
const status = (row.original as any).status const status = (row.original as any).status
const variantMap: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary",
open: "default",
un_paid: "destructive",
partially_paid: "secondary",
paid: "default",
}
return ( return (
<Badge variant={status === "paid" ? "default" : "secondary"}> <Badge variant={variantMap[status] ?? "outline"}>
{status || "—"} {formatEnum(status) || "—"}
</Badge> </Badge>
) )
}, },
}, },
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(), actionsColumn(),
]} ]}
/> />

View File

@ -20,11 +20,11 @@ export default function PurchaseOrdersPage() {
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog classNames={{ dialogContent: "min-w-6xl" }} title="Purchase Order"> <FormDialog classNames={{ dialogContent: "min-w-6xl" }} title="Purchase Order">
{(resourceId) => ( {(resourceId, { close }) => (
<PurchaseOrderForm <PurchaseOrderForm
resourceId={resourceId} resourceId={resourceId}
initialData={selectedItem} initialData={selectedItem}
onSuccess={invalidateQuery} onSuccess={() => { invalidateQuery(); close()}}
/> />
)} )}
</FormDialog> </FormDialog>

View File

@ -3,6 +3,7 @@ import { getServerApi } from '@garage/api/server'
import { EstimateActions } from '@/modules/estimates/estimate-actions' import { EstimateActions } from '@/modules/estimates/estimate-actions'
import { EstimateProvider } from '@/modules/estimates/estimate-context' import { EstimateProvider } from '@/modules/estimates/estimate-context'
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button' import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
import { CreateJobCardFromEstimateButton } from '@/modules/estimates/create-job-card-from-estimate-button'
import { FileTextIcon } from 'lucide-react' import { FileTextIcon } from 'lucide-react'
import React from 'react' import React from 'react'
@ -12,9 +13,9 @@ export default async function layout(props: {
}) { }) {
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const estimate = await api.estimates.getById(id) const estimate = await api.estimates.show(id)
const estimateData = (estimate as any)?.data const estimateData = estimate?.data
const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}` const title = estimateData?.title || estimateData?.estimate_number || `Estimate #${id}`
const estimateLabel = estimateData?.estimate_number const estimateLabel = estimateData?.estimate_number
? `${estimateData.estimate_number}${estimateData.title ? `${estimateData.title}` : ''}` ? `${estimateData.estimate_number}${estimateData.title ? `${estimateData.title}` : ''}`
@ -33,6 +34,7 @@ export default async function layout(props: {
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreateInvoiceFromEstimateButton /> <CreateInvoiceFromEstimateButton />
<CreateJobCardFromEstimateButton />
<EstimateActions estimateId={id} /> <EstimateActions estimateId={id} />
</div> </div>
} }

View File

@ -8,9 +8,9 @@ import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) { export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const estimate = await api.estimates.getById(id) const estimate = await api.estimates.show(id)
const estimateData = (estimate as any)?.data const estimateData = estimate?.data
if (!estimateData) { if (!estimateData) {
return <div className="text-muted-foreground p-4">Estimate not found.</div> return <div className="text-muted-foreground p-4">Estimate not found.</div>

View File

@ -4,6 +4,7 @@ import { InvoiceActions } from '@/modules/invoices/invoice-actions'
import { InvoiceProvider } from '@/modules/invoices/invoice-context' import { InvoiceProvider } from '@/modules/invoices/invoice-context'
import { ReceiptIcon } from 'lucide-react' import { ReceiptIcon } from 'lucide-react'
import React from 'react' import React from 'react'
import InvoiceStatusBadge from '@/modules/invoices/invoice-status-badge'
export default async function InvoiceDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) { export default async function InvoiceDetailLayout(props: { params: Promise<{ id: string }>, children: React.ReactNode }) {
const { id } = await props.params const { id } = await props.params
@ -13,19 +14,26 @@ export default async function InvoiceDetailLayout(props: { params: Promise<{ id:
const title = data?.subject || data?.invoice_number || 'Invoice Details' const title = data?.subject || data?.invoice_number || 'Invoice Details'
return ( return (
<InvoiceProvider invoice={{ id, label: title }}> <InvoiceProvider invoice={data}>
<DashboardDetailsPage <DashboardDetailsPage
className='p-0 lg:p-0' className='p-0 lg:p-0'
title={title} title={title}
description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined} description={data?.invoice_number ? `Invoice #: ${data.invoice_number}` : undefined}
icon={<ReceiptIcon className="size-5" />} icon={<ReceiptIcon className="size-5" />}
backHref="/sales/invoice" backHref="/sales/invoice"
actions={<InvoiceActions invoiceId={id} />} actions={
<div className="flex space-x-2 items-center">
<InvoiceStatusBadge invoice={{id, status:data?.status}} />
<InvoiceActions invoiceId={id} />
</div>
}
tabs={[ tabs={[
{ {
href: `/sales/invoice/${id}`, href: `/sales/invoice/${id}`,
label: 'Details' label: 'Details'
}, },
{ {
href: `/sales/invoice/${id}/documents`, href: `/sales/invoice/${id}/documents`,
label: 'Documents' label: 'Documents'

View File

@ -3,7 +3,10 @@ import { InvoiceGeneralInfo } from '@/modules/invoices/invoice-general-info'
import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section' import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section'
import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-section' import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-section'
import { InvoiceExpensesSection } from '@/modules/invoices/invoice-expenses-section' import { InvoiceExpensesSection } from '@/modules/invoices/invoice-expenses-section'
import { InvoiceTotalsSummary } from '@/modules/invoices/invoice-totals-summary'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import InvoicePaymentsSection from '@/modules/invoices/invoice-payments-section'
export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) { export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params const { id } = await props.params
@ -18,10 +21,12 @@ export default async function InvoiceDetailPage(props: { params: Promise<{ id: s
return ( return (
<DashboardPage header={null}> <DashboardPage header={null}>
<div className="grid gap-6"> <div className="grid gap-6">
<InvoiceGeneralInfo invoice={data} /> <InvoiceGeneralInfo />
<InvoicePartsSection parts={data.invoice_parts} /> <InvoicePartsSection parts={data.invoice_parts} />
<InvoiceServicesSection services={data.invoice_services} /> <InvoiceServicesSection services={data.invoice_services} />
<InvoiceExpensesSection expenses={data.invoice_expenses} /> <InvoiceExpensesSection expenses={data.invoice_expenses} />
<InvoicePaymentsSection></InvoicePaymentsSection>
<InvoiceTotalsSummary />
</div> </div>
</DashboardPage> </DashboardPage>
) )

View File

@ -30,7 +30,7 @@ export default function InvoicesPage() {
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)} onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({ headerProps={({ selectedItem, invalidateQuery }) => ({
actions: ( actions: (
<FormDialog title="Invoice"> <FormDialog classNames={{dialogContent:'lg:min-w-6xl'}} title="Invoice">
{(resourceId) => ( {(resourceId) => (
<InvoiceForm <InvoiceForm
resourceId={resourceId} resourceId={resourceId}

View File

@ -0,0 +1,207 @@
"use client"
import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
import { Ellipsis, Plus } from "lucide-react"
import type { ColumnDef } from "@tanstack/react-table"
import { JobCardExpenseItemForm } from "@/modules/job-cards/job-card-expense-item-form"
import { formatDate } from "@/shared/utils/formatters"
// TODO: expense items invalidation is not working properly when create new expense item line. Need to investigate why and fix it.
export default function JobCardExpenseItemsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const api = useAuthApi()
const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-expense-items", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false)
const [editItem, setEditItem] = useState<any | null>(null)
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.jobCards.getExpenseItems(jobCardId),
})
const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) {
const confirmed = await confirm({
title: "Delete this expense item?",
description: `Remove "${row.expense_item?.item_name ?? "this expense item"}" from the job card?`,
})
if (!confirmed) return
const promise = api.jobCards.deleteExpenseItem(jobCardId, row.id)
toast.promise(promise, {
loading: "Deleting...",
success: "Expense item deleted",
error: "Failed to delete expense item",
})
await promise
invalidate()
}
const columns: ColumnDef<any>[] = [
{
accessorKey: "expense_item.item_name",
header: ({ column }) => <ColumnHeader column={column} title="Expense Item" />,
cell: ({ row }) => {
const item = row.original.expense_item
return item ? (
<div>
<span className="font-medium">{item.item_name}</span>
{item.sku && (
<span className="ml-2 text-xs text-muted-foreground">{item.sku}</span>
)}
</div>
) : "—"
},
},
{
accessorKey: "quantity",
header: ({ column }) => <ColumnHeader column={column} title="Qty" />,
cell: ({ row }) => row.original.quantity ?? "—",
},
{
accessorKey: "rate",
header: ({ column }) => <ColumnHeader column={column} title="Rate" />,
cell: ({ row }) => {
const val = row.original.rate
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax_id ?? "—",
},
{
accessorKey: "department.name",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => row.original.department?.name || "—",
},
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => row.original.description || "—",
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Added" />,
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "actions",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setEditItem(row.original)
setDialogOpen(true)
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(row.original)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex justify-end">
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditItem(null)
}}
>
<DialogTrigger asChild>
<Button onClick={() => setEditItem(null)}>
<Plus className="me-2 h-4 w-4" />
Add Expense Item
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editItem ? "Edit Expense Item" : "Add Expense Item"}</DialogTitle>
</DialogHeader>
<JobCardExpenseItemForm
jobCardId={jobCardId}
jobCardExpenseItemId={editItem?.id ?? null}
initialData={editItem}
onSuccess={() => {
setDialogOpen(false)
setEditItem(null)
invalidate()
}}
onCancel={() => {
setDialogOpen(false)
setEditItem(null)
}}
/>
</DialogContent>
</Dialog>
</div>
<DataTable
columns={columns}
data={rows}
pagination={{
page: 1,
pageSize: rows.length || 15,
pageCount: 1,
total: rows.length,
}}
isLoading={isLoading}
/>
</div>
)
}

View File

@ -1,92 +0,0 @@
"use client"
import { use } from "react"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { ExpenseForm } from "@/modules/expenses/expense-form"
import { Badge } from "@/shared/components/ui/badge"
import { EXPENSE_ROUTES } from "@garage/api"
import type { ExpensesClient } from "@garage/api"
import { useJobCard } from "@/modules/job-cards/job-card-context"
export default function JobCardExpensesPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: jobCardId } = use(params)
const jobCard = useJobCard()
const defaultJobCard = jobCard
? { value: String((jobCard as any).id), label: (jobCard as any).label || (jobCard as any).title || `Job Card` }
: null
return (
<ResourcePage<ExpensesClient>
routeKey={EXPENSE_ROUTES.INDEX}
getClient={(api) => api.expenses}
extraParams={{ job_card_id: jobCardId }}
header={null}
tableHeader={({ invalidateQuery, selectedItem, closeDialog }) => (
<div className="flex justify-end">
<FormDialog title="Expense">
{(resourceId) => (
<ExpenseForm
resourceId={resourceId}
initialData={selectedItem ?? { job_card: defaultJobCard }}
onSuccess={() => { closeDialog(); invalidateQuery() }}
/>
)}
</FormDialog>
</div>
)}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "invoice_number",
header: ({ column }) => <ColumnHeader column={column} title="Invoice #" />,
cell: ({ row }) => (row.original as any).invoice_number || "—",
},
{
accessorKey: "vendor_name",
header: ({ column }) => <ColumnHeader column={column} title="Vendor" />,
cell: ({ row }) => (row.original as any).vendor_name || "—",
},
{
accessorKey: "expense_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const val = (row.original as any).expense_date
return val ? new Date(val).toLocaleDateString() : "—"
},
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<Badge variant={status === "paid" ? "default" : "secondary"}>
{status || "—"}
</Badge>
)
},
},
{
accessorKey: "created_at",
header: ({ column }) => <ColumnHeader column={column} title="Created" />,
cell: ({ row }) => {
const val = (row.original as any).created_at
return val ? new Date(val).toLocaleDateString() : "—"
},
},
actionsColumn(),
]}
/>
)
}

View File

@ -12,7 +12,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const jobCard = await api.jobCards.show(id).then(res => res.data) const jobCard:any = await api.jobCards.show(id).then(res => res.data)
const title = jobCard?.title || 'Job Card Details' const title = jobCard?.title || 'Job Card Details'
const status = jobCard?.status || 'draft' const status = jobCard?.status || 'draft'
@ -35,42 +35,53 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
href: `/sales/job-cards/${id}`, href: `/sales/job-cards/${id}`,
label: 'Details' label: 'Details'
}, },
{
href: `/sales/job-cards/${id}/attachments`,
label: `Attachments (${docs?.length || 0})`
},
{
href: `/sales/job-cards/${id}/inspections`,
label: `Inspections (${(jobCard as any)?.inspections_count || 0})`
},
{
href: `/sales/job-cards/${id}/purchase-orders`,
label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
},
{
href: `/sales/job-cards/${id}/bills`,
label: `Bills (${jobCard?.bills_count || 0})`
},
{
href: `/sales/job-cards/${id}/appointments`,
label: `Appointments (${jobCard?.appointments_count || 0})`
},
{
href: `/sales/job-cards/${id}/expenses`,
label: `Expenses (${jobCard?.expenses_count || 0})`
},
{
href: `/sales/job-cards/${id}/tasks`,
label: `Tasks (${jobCard?.tasks_count || 0})`
},
{ {
href: `/sales/job-cards/${id}/parts`, href: `/sales/job-cards/${id}/parts`,
label: `Parts (${jobCard?.parts_count || 0})` label: `Parts (${jobCard?.parts_count || 0})`
}, },
{ {
href: `/sales/job-cards/${id}/services`, href: `/sales/job-cards/${id}/services`,
label: `Services` label: `Services (${jobCard?.services_count })`
}, },
{
href: `/sales/job-cards/${id}/expense-items`,
label: `Expense Items (${jobCard?.expense_items_count || 0})`
},
// TODO: Needs refactor from API side then refactor in frontend
{
href: `/sales/job-cards/${id}/attachments`,
label: `Attachments (${docs?.length || 0})`
},
// {
// href: `/sales/job-cards/${id}/inspections`,
// label: `Inspections (${(jobCard as any)?.inspections_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/appointments`,
// label: `Appointments (${jobCard?.appointments_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/tasks`,
// label: `Tasks (${jobCard?.tasks_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/purchase-orders`,
// label: `Purchase Orders (${jobCard?.purchase_orders_count || 0})`
// },
// {
// href: `/sales/job-cards/${id}/bills`,
// label: `Bills (${jobCard?.bills_count || 0})`
// },
]} ]}
> >
{props.children} {props.children}

View File

@ -1,12 +1,13 @@
import { getServerApi } from '@garage/api/server' import { getServerApi } from '@garage/api/server'
import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info' import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page' import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
import type { JobCardShowData } from '@garage/api'
export default async function JobCardDetailPage(props: { params: Promise<{ id: string }> }) { export default async function JobCardDetailPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params const { id } = await props.params
const api = await getServerApi() const api = await getServerApi()
const jobCard = await api.jobCards.show(id) const response = await api.jobCards.show(id)
const data = (jobCard as any)?.data ?? jobCard const data = response.data
if (!data) { if (!data) {
return <div className="text-muted-foreground">Job card not found.</div> return <div className="text-muted-foreground">Job card not found.</div>

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { use, useState } from "react" import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view" import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
@ -33,6 +34,7 @@ export default function JobCardPartsPage({
const { id: jobCardId } = use(params) const { id: jobCardId } = use(params)
const api = useAuthApi() const api = useAuthApi()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-parts", jobCardId] const queryKey = ["job-card-parts", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@ -45,7 +47,7 @@ export default function JobCardPartsPage({
const rows = (data as any)?.data ?? [] const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey }) const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) { async function handleDelete(row: any) {
const confirmed = await confirm({ const confirmed = await confirm({
@ -93,9 +95,17 @@ export default function JobCardPartsPage({
}, },
}, },
{ {
accessorKey: "tax", accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />, header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax || "—", cell: ({ row }) => row.original.tax_id ?? "—",
}, },
{ {
accessorKey: "department.name", accessorKey: "department.name",

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { use, useState } from "react" import { use, useState } from "react"
import { useRouter } from "next/navigation"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { ColumnHeader, DataTable } from "@/shared/data-view/table-view" import { ColumnHeader, DataTable } from "@/shared/data-view/table-view"
@ -26,6 +27,8 @@ import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
import { formatDate } from "@/shared/utils/formatters" import { formatDate } from "@/shared/utils/formatters"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
// TODO: services invalidation is not working properly when create new service line. Need to investigate why and fix it.
export default function JobCardServicesPage({ export default function JobCardServicesPage({
params, params,
}: { }: {
@ -34,6 +37,7 @@ export default function JobCardServicesPage({
const { id: jobCardId } = use(params) const { id: jobCardId } = use(params)
const api = useAuthApi() const api = useAuthApi()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const router = useRouter()
const queryKey = ["job-card-services", jobCardId] const queryKey = ["job-card-services", jobCardId]
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@ -46,7 +50,7 @@ export default function JobCardServicesPage({
const rows = (data as any)?.data ?? [] const rows = (data as any)?.data ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey }) const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
async function handleDelete(row: any) { async function handleDelete(row: any) {
const confirmed = await confirm({ const confirmed = await confirm({
@ -125,9 +129,17 @@ export default function JobCardServicesPage({
cell: ({ row }) => row.original.labor_hours ?? "—", cell: ({ row }) => row.original.labor_hours ?? "—",
}, },
{ {
accessorKey: "tax", accessorKey: "discount_amount",
header: ({ column }) => <ColumnHeader column={column} title="Discount" />,
cell: ({ row }) => {
const val = row.original.discount_amount
return val != null && val > 0 ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "tax_id",
header: ({ column }) => <ColumnHeader column={column} title="Tax" />, header: ({ column }) => <ColumnHeader column={column} title="Tax" />,
cell: ({ row }) => row.original.tax || "—", cell: ({ row }) => row.original.tax_id ?? "—",
}, },
{ {
accessorKey: "department.name", accessorKey: "department.name",

View File

@ -1,17 +1,17 @@
"use client" "use client"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
import { SettingsForm } from "@/modules/settings/company/settings-form" import { SettingsForm } from "@/modules/settings/company/settings-form"
export default function CompanySettingsPage() { export default function CompanySettingsPage() {
return ( return (
<div className="mx-auto max-w-3xl space-y-6 p-6"> <DashboardPage headerProps={{title:'Company Settings'}}>
<div> <div>
<h1 className="text-2xl font-semibold">Company Settings</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Manage your workshop profile, contact details, and preferences. Manage your workshop profile, contact details, and preferences.
</p> </p>
</div> </div>
<SettingsForm /> <SettingsForm />
</div> </DashboardPage>
) )
} }

View File

@ -75,7 +75,7 @@ export const navGroups: NavGroup[] = [
href: "/calendars", href: "/calendars",
icon: <CalendarIcon />, icon: <CalendarIcon />,
items: [ items: [
{ title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> }, // { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> },
{ title: "Appointments", href: "/calendar/appointment/list", icon: <CalendarCheck2Icon /> }, { title: "Appointments", href: "/calendar/appointment/list", icon: <CalendarCheck2Icon /> },
], ],
}, },
@ -144,10 +144,10 @@ export const navGroups: NavGroup[] = [
{ title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> }, { title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> },
{ title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> }, { title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> },
{ title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> }, { title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> },
{ title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> }, // { title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
{ title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> }, // { title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> }, // { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> }, // { title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> }, { title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
{ title: "Tasks", href: "/productivity/tasks", icon: <ListTodoIcon /> }, { title: "Tasks", href: "/productivity/tasks", icon: <ListTodoIcon /> },
], ],
@ -161,7 +161,7 @@ export const navGroups: NavGroup[] = [
{ title: "Parts", href: "/items/parts", icon: <WrenchIcon /> }, { title: "Parts", href: "/items/parts", icon: <WrenchIcon /> },
{ title: "Expense Item", href: "/items/expense-item", icon: <WalletIcon /> }, { title: "Expense Item", href: "/items/expense-item", icon: <WalletIcon /> },
{ title: "Service Group", href: "/items/service-group", icon: <PackageIcon /> }, { title: "Service Group", href: "/items/service-group", icon: <PackageIcon /> },
{ title: "Inspections", href: "/items/inspection", icon: <ClipboardCheckIcon /> }, // { title: "Inspections", href: "/items/inspection", icon: <ClipboardCheckIcon /> },
{ title: "Inventory Adjustments", href: "/items/adjustment", icon: <ListIcon /> }, { title: "Inventory Adjustments", href: "/items/adjustment", icon: <ListIcon /> },
], ],
}, },
@ -177,9 +177,9 @@ export const navGroups: NavGroup[] = [
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> }, { title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
{ title: "Make & Models", href: "/settings/make-and-models", icon: <CarIcon /> }, { title: "Make & Models", href: "/settings/make-and-models", icon: <CarIcon /> },
{ title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> }, { title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> },
{ title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> }, // { title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
{ title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> }, // { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> },
{ title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> }, // { title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> },
], ],
}, },
], ],

View File

@ -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>
)
}

View File

@ -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)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -5,24 +5,39 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert" import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field" import { FieldGroup } from "@/shared/components/ui/field"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { import {
Rhform, Rhform,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfSelectField, RhfSelectField,
RhfTextField, RhfTextField,
RhfTextareaField, RhfTextareaField,
RhfAutoGenerateField,
RhfDateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation" 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 { 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 { toast } from "sonner"
import { PartsSelectorField } from "@/modules/parts/parts-selector-field" import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field" import { ServicesSelectorField } from "@/modules/services/services-selector-field"
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-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 { billFormSchema, type BillFormValues } from "./bill.schema"
import { BillFormSummary } from "./bill-form-summary"
export type BillFormProps = { export type BillFormProps = {
resourceId?: string | null resourceId?: string | null
@ -31,16 +46,20 @@ export type BillFormProps = {
} }
const DEFAULT_VALUES: BillFormValues = { const DEFAULT_VALUES: BillFormValues = {
title: "",
vendor: null, vendor: null,
vendor_address: null,
purchase_order: null, purchase_order: null,
job_card: null, job_card: null,
payment_term: null, payment_term: null,
department: null, department: null,
title: "", tax: null,
bill_number: "", bill_number: "",
bill_date: "", bill_date: getTodayDate(),
bill_due_date: "", bill_due_date: "",
status: "draft", status: "draft",
discount: "no",
discount_amount: undefined,
notes: "", notes: "",
part_items: [], part_items: [],
service_items: [], service_items: [],
@ -49,12 +68,17 @@ const DEFAULT_VALUES: BillFormValues = {
const STATUS_OPTIONS = BillStatus.map((value) => ({ const STATUS_OPTIONS = BillStatus.map((value) => ({
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) => ({ const mapLookupOption = (item: any) => ({
value: String(item.id), 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 } 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 ?? {} const d = (data as any)?.data ?? data ?? {}
return { 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 || "", 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_number: d.bill_number || "",
bill_date: d.bill_date || "", bill_date: d.bill_date ? d.bill_date.split("T")[0] : "",
bill_due_date: d.bill_due_date || "", bill_due_date: d.bill_due_date ? d.bill_due_date.split("T")[0] : "",
status: d.status || "draft", status: d.status || "draft",
discount: d.discount_type || "no",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
notes: d.notes || "", notes: d.notes || "",
part_items: (d.parts ?? []).map((p: any) => ({ part_items: (d.parts ?? []).map((p: any) => ({
part_id: p.part_id ?? p.id, 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, quantity: Number(p.quantity) || 1,
rate: Number(p.rate) || 0, rate: Number(p.rate) || 0,
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
description: p.description ?? "", description: p.description ?? "",
})), })),
service_items: (d.services ?? []).map((s: any) => ({ service_items: (d.services ?? []).map((s: any) => ({
service_id: s.service_id ?? s.id, 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, quantity: Number(s.quantity) || 1,
rate: Number(s.rate) || 0, rate: Number(s.rate) || 0,
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
description: s.description ?? "", description: s.description ?? "",
})), })),
expense_items: (d.expenses ?? []).map((e: any) => ({ expense_items: (d.expenses ?? []).map((e: any) => ({
expense_id: e.expense_id ?? e.id, 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, quantity: Number(e.quantity) || 1,
rate: Number(e.rate) || 0, rate: Number(e.rate) || 0,
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
description: e.description ?? "", description: e.description ?? "",
})), })),
} }
@ -102,36 +133,61 @@ function mapFormToPayload(values: BillFormValues) {
return { return {
title: values.title, title: values.title,
vendor_id: toId(values.vendor), vendor_id: toId(values.vendor),
vendor_address_id: toId(values.vendor_address),
purchase_order_id: toId(values.purchase_order), purchase_order_id: toId(values.purchase_order),
job_card_id: toId(values.job_card), job_card_id: toId(values.job_card),
payment_terms_id: toId(values.payment_term), payment_terms_id: toId(values.payment_term),
department_id: toId(values.department), department_id: toId(values.department),
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
bill_number: values.bill_number || undefined, bill_number: values.bill_number || undefined,
bill_date: values.bill_date || undefined, bill_date: values.bill_date || undefined,
bill_due_date: values.bill_due_date || undefined, bill_due_date: values.bill_due_date || undefined,
status: values.status || 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, notes: values.notes || undefined,
part_items: (values.part_items ?? []).map((item) => ({ part_items: (values.part_items ?? []).map((item) => ({
part_id: item.part_id, part_id: item.part_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || undefined, description: item.description || undefined,
})), })),
service_items: (values.service_items ?? []).map((item) => ({ service_items: (values.service_items ?? []).map((item) => ({
service_id: item.service_id, service_id: item.service_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || undefined, description: item.description || undefined,
})), })),
expense_items: (values.expense_items ?? []).map((item) => ({ expense_items: (values.expense_items ?? []).map((item) => ({
expense_id: item.expense_id, expense_id: item.expense_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || 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) { export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
const api = useAuthApi() const api = useAuthApi()
@ -140,22 +196,24 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
queryKey: [BILL_ROUTES.BY_ID, resourceId],
mapToFormValues, mapToFormValues,
}) })
const discount = form.watch("discount")
const isLineItemDiscount = discount === "line_item_level"
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: BillFormValues) => { mutationFn: (values: BillFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values)
const promise = isEditing && resourceId const promise = (isEditing && resourceId
? api.bills.update(resourceId, payload) ? api.bills.update(resourceId, payload)
: api.bills.create(payload) : api.bills.create(payload)) as Promise<any>
toast.promise(promise, { toast.promise(promise, {
loading: isEditing ? "Updating bill..." : "Creating bill...", loading: isEditing ? "Updating bill..." : "Creating bill...",
success: isEditing ? "Bill updated successfully" : "Bill created successfully", success: isEditing ? "Bill updated successfully" : "Bill created successfully",
error: isEditing ? "Failed to update bill" : "Failed to create bill", error: isEditing ? "Failed to update bill" : "Failed to create bill",
}) })
return promise return promise
}, },
onSuccess: () => { onSuccess: () => {
@ -169,25 +227,59 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
{error && ( {error && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-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} {error.message}
</Alert> </Alert>
)} )}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
{/* ── Main column (9/12) ── */}
<div className="lg:col-span-9">
<FieldGroup> <FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required /> <RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="bill_number" label="Bill Number" placeholder="e.g. BILL-001" /> <RhfAutoGenerateField autoFetch table="bills" name="bill_number" label="Bill Number" placeholder="e.g. BILL-001" />
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} /> <RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="bill_date" label="Bill Date" type="date" /> <RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
<RhfTextField name="bill_due_date" label="Due Date" type="date" /> </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>
{/* ── 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> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="vendor" name="vendor"
label="Vendor" label="Vendor"
@ -197,6 +289,20 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...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 <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
@ -206,28 +312,29 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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 <RhfAsyncSelectField
name="job_card" name="job_card"
label="Job Card" label="Job Card"
placeholder="Select job card" placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]} queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()} listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })} mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number || item.estimate_number || `#${item.id}`,
})}
{...STORE_OBJECT} {...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 <RhfAsyncSelectField
name="purchase_order" name="purchase_order"
@ -241,30 +348,20 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
})} })}
{...STORE_OBJECT} {...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> </FieldGroup>
</CardContent>
</Card>
<div className="mt-4">
<BillFormSummary />
</div>
</div>
</div>
</Rhform> </Rhform>
) )
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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(),
]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -9,6 +9,7 @@ const billPartItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -17,6 +18,7 @@ const billServiceItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -25,21 +27,33 @@ const billExpenseItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
const billFormSchema = z.object({ const billFormSchema = z.object({
// ── Required ──
title: z.string().min(1, "Title is required"),
// ── Relations ──
vendor: relationFieldSchema, vendor: relationFieldSchema,
vendor_address: relationFieldSchema,
purchase_order: relationFieldSchema, purchase_order: relationFieldSchema,
job_card: relationFieldSchema, job_card: relationFieldSchema,
payment_term: relationFieldSchema, payment_term: relationFieldSchema,
department: relationFieldSchema, department: relationFieldSchema,
title: z.string().min(1, "Title is required"), tax: relationFieldSchema,
// ── Optional fields ──
bill_number: z.string().optional(), bill_number: z.string().optional(),
bill_date: z.string().optional(), bill_date: z.string().optional(),
bill_due_date: z.string().optional(), bill_due_date: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
discount: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
notes: z.string().optional(), notes: z.string().optional(),
// ── Line items ──
part_items: z.array(billPartItemSchema).optional(), part_items: z.array(billPartItemSchema).optional(),
service_items: z.array(billServiceItemSchema).optional(), service_items: z.array(billServiceItemSchema).optional(),
expense_items: z.array(billExpenseItemSchema).optional(), expense_items: z.array(billExpenseItemSchema).optional(),

View File

@ -0,0 +1,64 @@
"use client"
import { useState } from "react"
import { ClipboardList } from "lucide-react"
import { useRouter } from "next/navigation"
import { ApiError } from "@garage/api"
import { Button } from "@/shared/components/ui/button"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEstimate } from "./estimate-context"
export function CreateJobCardFromEstimateButton() {
const [isConverting, setIsConverting] = useState(false)
const estimateContext = useEstimate()
const api = useAuthApi()
const router = useRouter()
const estimateId = estimateContext?.id ?? ""
if (!estimateContext || !estimateId) return null
const handleConvert = async () => {
const confirmed = await confirm({
title: "Generate Job Card",
description: "This will create a job card from this estimate. Do you want to continue?",
confirmLabel: "Generate",
})
if (!confirmed) return
setIsConverting(true)
try {
const response = await api.estimates.convertToJobCard(estimateId, {})
const jobCardId = response?.data?.id
toast.success("Estimate converted to job card successfully")
if (jobCardId) {
router.push(`/sales/job-cards/${jobCardId}`)
}
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
const jobCardId = (error.payload?.data as { job_card_id?: number } | undefined)?.job_card_id
toast.info("A job card already exists for this estimate.")
if (jobCardId) {
router.push(`/sales/job-cards/${jobCardId}`)
return
}
}
toast.error("Failed to convert estimate to job card")
} finally {
setIsConverting(false)
}
}
return (
<Button variant="outline" size="sm" onClick={handleConvert} disabled={isConverting}>
<ClipboardList className="me-2 size-4" />
{isConverting ? "Generating..." : "Generate Job Card"}
</Button>
)
}

View File

@ -8,10 +8,13 @@ import { FieldGroup } from "@/shared/components/ui/field"
import { import {
Rhform, Rhform,
RhfTextField, RhfTextField,
RhfTextareaField,
RhfSelectField, RhfSelectField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfCheckboxField, RhfCheckboxField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { UnitTypeInlineForm } from "@/modules/services/inline-forms/unit-type-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
@ -19,7 +22,13 @@ import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils" import { toRelation, toId } from "@/shared/lib/utils"
import { expenseItemFormSchema, type ExpenseItemFormValues } from "./expense-item.schema" import { expenseItemFormSchema, type ExpenseItemFormValues } from "./expense-item.schema"
import { EXPENSE_ITEM_ROUTES, INVENTORY_CATEGORY_ROUTES } from "@garage/api" import {
EXPENSE_ITEM_ROUTES,
INVENTORY_CATEGORY_ROUTES,
INVENTORY_ROUTES,
DEPARTMENT_ROUTES,
VENDOR_ROUTES,
} from "@garage/api"
import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog" import { InventoryCategoryCrudDialog } from "./inventory-category-crud-dialog"
// ── Constants ── // ── Constants ──
@ -48,10 +57,19 @@ export type ExpenseItemFormProps = {
const DEFAULT_VALUES: ExpenseItemFormValues = { const DEFAULT_VALUES: ExpenseItemFormValues = {
item_type: "Expense", item_type: "Expense",
item_name: "", item_name: "",
sku: "",
item_code: "",
description: "",
category: null, category: null,
unit_type: null,
department: null,
purchase_information: true,
purchase_price: undefined, purchase_price: undefined,
purchase_chart_of_account: "", purchase_chart_of_account: "",
purchase_information: true, purchase_preferred_vendor: null,
sales_information: false,
selling_price: undefined,
sales_chart_of_account: "",
is_active: true, is_active: true,
} }
@ -63,10 +81,22 @@ function mapToFormValues(data: unknown): ExpenseItemFormValues {
return { return {
item_type: d.item_type || "Expense", item_type: d.item_type || "Expense",
item_name: d.item_name || "", item_name: d.item_name || "",
sku: d.sku || "",
item_code: d.item_code || "",
description: d.description || "",
category: toRelation(d.category_id, d.category_title ?? d.category_name), category: toRelation(d.category_id, d.category_title ?? d.category_name),
unit_type: toRelation(d.unit_type_id, d.unit_type_title ?? d.unit_type_name),
department: toRelation(d.department_id, d.department_name ?? d.department_title),
purchase_information: d.purchase_information ?? true,
purchase_price: d.purchase_price ?? undefined, purchase_price: d.purchase_price ?? undefined,
purchase_chart_of_account: d.purchase_chart_of_account || "", purchase_chart_of_account: d.purchase_chart_of_account || "",
purchase_information: d.purchase_information ?? true, purchase_preferred_vendor: toRelation(
d.purchase_preferred_vendor_id,
d.purchase_preferred_vendor_name,
),
sales_information: d.sales_information ?? false,
selling_price: d.selling_price ?? undefined,
sales_chart_of_account: d.sales_chart_of_account || "",
is_active: d.is_active ?? true, is_active: d.is_active ?? true,
} }
} }
@ -75,10 +105,19 @@ function mapFormToPayload(values: ExpenseItemFormValues) {
return { return {
item_type: values.item_type, item_type: values.item_type,
item_name: values.item_name, item_name: values.item_name,
sku: values.sku || undefined,
item_code: values.item_code || undefined,
description: values.description || undefined,
category_id: toId(values.category), category_id: toId(values.category),
unit_type_id: toId(values.unit_type),
department_id: toId(values.department),
purchase_information: values.purchase_information,
purchase_price: values.purchase_price, purchase_price: values.purchase_price,
purchase_chart_of_account: values.purchase_chart_of_account || undefined, purchase_chart_of_account: values.purchase_chart_of_account || undefined,
purchase_information: values.purchase_information, purchase_preferred_vendor_id: toId(values.purchase_preferred_vendor),
sales_information: values.sales_information,
selling_price: values.selling_price,
sales_chart_of_account: values.sales_chart_of_account || undefined,
is_active: values.is_active, is_active: values.is_active,
} }
} }
@ -129,6 +168,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
)} )}
<FieldGroup> <FieldGroup>
{/* Basic Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField <RhfSelectField
name="item_type" name="item_type"
@ -144,6 +184,27 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="sku"
label="SKU"
placeholder="e.g. EXP-001"
/>
<RhfTextField
name="item_code"
label="Item Code"
placeholder="e.g. EXP-001"
/>
</div>
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional description"
rows={3}
/>
{/* Classification */}
<div> <div>
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">Category</span> <span className="text-sm font-medium">Category</span>
@ -160,6 +221,37 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="unit_type"
label="Unit Type"
placeholder="Select unit type"
queryKey={[INVENTORY_ROUTES.UNIT_TYPES]}
listFn={() => api.inventory.listUnitTypes()}
mapOption={mapLookupOption}
createForm={(props) => <UnitTypeInlineForm {...props} />}
createLabel="Unit Type"
{...STORE_OBJECT}
/>
<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 ?? String(item.id) })}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
</div>
{/* Purchase Information */}
{/* <RhfCheckboxField
name="purchase_information"
label="Purchase Information"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="purchase_price" name="purchase_price"
@ -174,16 +266,41 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
/> />
</div> </div>
<div className="flex flex-col gap-3"> <RhfAsyncSelectField
<RhfCheckboxField name="purchase_preferred_vendor"
name="purchase_information" label="Preferred Vendor"
label="Purchase Information" placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.name ?? String(item.id) })}
{...STORE_OBJECT}
/> */}
{/* Sales Information */}
{/* <RhfCheckboxField
name="sales_information"
label="Sales Information"
/> />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="selling_price"
label="Selling Price"
placeholder="0.00"
type="number"
/>
<RhfTextField
name="sales_chart_of_account"
label="Sales Chart of Account"
placeholder="e.g. Revenue"
/>
</div> */}
{/* Status */}
<RhfCheckboxField <RhfCheckboxField
name="is_active" name="is_active"
label="Active" label="Active"
/> />
</div>
</FieldGroup> </FieldGroup>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">

View File

@ -7,10 +7,22 @@ export const relationFieldSchema = z
export const expenseItemFormSchema = z.object({ export const expenseItemFormSchema = z.object({
item_type: z.string().min(1, "Item type is required"), item_type: z.string().min(1, "Item type is required"),
item_name: z.string().min(1, "Item name is required"), item_name: z.string().min(1, "Item name is required"),
sku: z.string().optional(),
item_code: z.string().optional(),
description: z.string().optional(),
category: relationFieldSchema, category: relationFieldSchema,
unit_type: relationFieldSchema,
department: relationFieldSchema,
// Purchase
purchase_information: z.boolean().default(true),
purchase_price: z.coerce.number().min(0).optional(), purchase_price: z.coerce.number().min(0).optional(),
purchase_chart_of_account: z.string().optional(), purchase_chart_of_account: z.string().optional(),
purchase_information: z.boolean().default(true), // purchase_preferred_vendor: relationFieldSchema,
// Sales
sales_information: z.boolean().default(false),
selling_price: z.coerce.number().min(0).optional(),
sales_chart_of_account: z.string().optional(),
// Status
is_active: z.boolean().default(true), is_active: z.boolean().default(true),
}) })

View File

@ -22,6 +22,8 @@ type ExpenseLineItem = {
title: string title: string
quantity: number quantity: number
rate: number rate: number
chart_of_account?: string
discount_amount?: number
description?: string description?: string
} }
@ -34,6 +36,8 @@ export type ExpenseItemsSelectorFieldProps<
name: TName & (TValues[TName] extends ExpenseItemsFieldConstraint ? TName : never) name: TName & (TValues[TName] extends ExpenseItemsFieldConstraint ? TName : never)
label?: string label?: string
triggerLabel?: string triggerLabel?: string
showChartOfAccount?: boolean
showDiscount?: boolean
} }
export function ExpenseItemsSelectorField< export function ExpenseItemsSelectorField<
@ -43,6 +47,8 @@ export function ExpenseItemsSelectorField<
name, name,
label = "Expense Items", label = "Expense Items",
triggerLabel = "Add Expense Items", triggerLabel = "Add Expense Items",
showChartOfAccount = false,
showDiscount = false,
}: ExpenseItemsSelectorFieldProps<TValues, TName>) { }: ExpenseItemsSelectorFieldProps<TValues, TName>) {
return ( return (
<RhfResourceField<TValues, TName, ExpenseItemsClient> <RhfResourceField<TValues, TName, ExpenseItemsClient>
@ -69,6 +75,7 @@ export function ExpenseItemsSelectorField<
title: r.item_name || "", title: r.item_name || "",
quantity: 1, quantity: 1,
rate: Number(r.purchase_price) || 0, rate: Number(r.purchase_price) || 0,
chart_of_account: r.purchase_chart_of_account ? String(r.purchase_chart_of_account) : "",
description: "", description: "",
} as any } as any
}} }}
@ -79,6 +86,8 @@ export function ExpenseItemsSelectorField<
<TableHead>Expense Item</TableHead> <TableHead>Expense Item</TableHead>
<TableHead className="w-24">Qty</TableHead> <TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead> <TableHead className="w-28">Rate</TableHead>
{showChartOfAccount && <TableHead className="w-36">Chart of Account</TableHead>}
{showDiscount && <TableHead className="w-28">Discount</TableHead>}
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="w-12" /> <TableHead className="w-12" />
</TableRow> </TableRow>
@ -110,6 +119,32 @@ export function ExpenseItemsSelectorField<
className="h-8 w-24" className="h-8 w-24"
/> />
</TableCell> </TableCell>
{showChartOfAccount && (
<TableCell>
<Input
value={item.chart_of_account ?? ""}
onChange={(e) =>
update(index, { ...item, chart_of_account: e.target.value } as any)
}
placeholder="Optional"
className="h-8 w-32"
/>
</TableCell>
)}
{showDiscount && (
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.discount_amount ?? 0}
onChange={(e) =>
update(index, { ...item, discount_amount: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
)}
<TableCell> <TableCell>
<Input <Input
value={item.description ?? ""} value={item.description ?? ""}

View File

@ -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 ExpenseActionsProps = {
expenseId: string
}
export function ExpenseActions({ expenseId }: ExpenseActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/purchase/expense/${expenseId}/edit`)
}
const handleDelete = async () => {
await api.expenses.destroy(expenseId)
router.push("/purchase/expense")
}
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>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { CrudShowResponse, ExpensesClient } from "@garage/api"
import { createContext, useContext } from "react"
export type ExpenseContextValue = CrudShowResponse<ExpensesClient>['data']
const ExpenseContext = createContext<ExpenseContextValue | null>(null)
export function ExpenseProvider({
expense,
children,
}: {
expense: ExpenseContextValue
children: React.ReactNode
}) {
return (
<ExpenseContext.Provider value={expense}>
{children}
</ExpenseContext.Provider>
)
}
export function useExpense() {
return useContext(ExpenseContext)
}

View File

@ -0,0 +1,62 @@
"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 { ExpenseFormValues } from "./expense.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 ExpenseFormSummary() {
const { watch } = useFormContext<ExpenseFormValues>()
const items = watch("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[] = items.map((item) => ({
quantity: item.quantity,
rate: item.rate,
discount_amount: item.discount_amount,
}))
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}
/>
</CardContent>
</Card>
)
}

View File

@ -5,29 +5,49 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert" import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field" import { FieldGroup } from "@/shared/components/ui/field"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { import {
Rhform, Rhform,
RhfTextField, RhfTextField,
RhfSelectField, RhfSelectField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfTextareaField, RhfTextareaField,
RhfDateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation" import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils" import { getTodayDate, toRelation, toId } from "@/shared/lib/utils"
import { import {
expenseFormSchema, expenseFormSchema,
type ExpenseFormValues, type ExpenseFormValues,
} from "./expense.schema" } from "./expense.schema"
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES, ExpenseStatus } from "@garage/api" import {
import { getFullName } from "@/shared/utils/getFullName" EXPENSE_ROUTES,
JOB_CARD_ROUTES,
VENDOR_ROUTES,
DEPARTMENT_ROUTES,
INVENTORY_CATEGORY_ROUTES,
TAX_ROUTES,
ExpenseStatus,
InvoiceDiscount,
} from "@garage/api"
import { useFormContext } from "react-hook-form"
import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field"
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
import { InventoryCategoryCrudDialog } from "@/modules/expense-items/inventory-category-crud-dialog"
import { ExpenseFormSummary } from "./expense-form-summary"
// ── Constants ── // ── Constants ──
const STATUS_OPTIONS = ExpenseStatus.map((v) => ({ const STATUS_OPTIONS = ExpenseStatus.map((value) => ({
value,
label: value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()),
}))
const DISCOUNT_OPTIONS = InvoiceDiscount.map((v) => ({
value: v, value: v,
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
})) }))
@ -47,11 +67,16 @@ const DEFAULT_VALUES: ExpenseFormValues = {
category: null, category: null,
vendor: null, vendor: null,
department: null, department: null,
tax: null,
title: "", title: "",
invoice_number: "", invoice_number: "",
expense_date: "", expense_date: getTodayDate(),
notes: "", notes: "",
status: "open", status: "open",
discount: "no",
discount_amount: undefined,
labels: [],
items: [],
} }
// ── Mapping helpers ── // ── Mapping helpers ──
@ -60,15 +85,32 @@ function mapToFormValues(data: unknown): ExpenseFormValues {
const d = (data as any)?.data ?? data ?? {} const d = (data as any)?.data ?? data ?? {}
return { return {
job_card: toRelation(d.job_card_id, d.job_card_name), job_card: toRelation(d.job_card_id, d.job_card?.order_number ?? d.job_card_name),
category: toRelation(d.category_id, d.category_name), category: toRelation(d.category_id, d.category?.name ?? d.category?.title ?? d.category_name),
vendor: toRelation(d.vendor_id, d.vendor_name), vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_name),
department: toRelation(d.department_id, d.department_name), department: toRelation(d.department_id, d.department?.name ?? d.department_name),
tax: toRelation(d.tax_id, d.tax?.title ? `${d.tax.title} (${d.tax.rate}%)` : undefined),
title: d.title || "", title: d.title || "",
invoice_number: d.invoice_number || "", invoice_number: d.invoice_number || "",
expense_date: d.expense_date || "", expense_date: d.expense_date ? d.expense_date.split("T")[0] : "",
notes: d.notes || "", notes: d.notes || "",
status: d.status || "open", status: d.status || "open",
discount: d.discount || "no",
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
labels: (d.labels ?? []).map((label: any): LabelItem => ({
id: Number(label.id),
title: label.title ?? label.name ?? "",
color_code: label.color_code ?? "",
})),
items: (d.expense_items ?? d.items ?? []).map((item: any) => ({
expense_id: item.expense_item_id ?? item.expense_item?.id ?? item.id,
title: item.expense_item?.item_name ?? item.expense_item?.name ?? item.item_name ?? item.title ?? "",
quantity: Number(item.quantity) || 1,
rate: Number(item.rate) || 0,
discount_amount: item.discount_amount != null ? Number(item.discount_amount) : undefined,
chart_of_account: item.chart_of_account != null ? String(item.chart_of_account) : "",
description: item.description ?? "",
})),
} }
} }
@ -78,11 +120,23 @@ function mapFormToPayload(values: ExpenseFormValues) {
category_id: toId(values.category), category_id: toId(values.category),
vendor_id: toId(values.vendor), vendor_id: toId(values.vendor),
department_id: toId(values.department), department_id: toId(values.department),
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
title: values.title, title: values.title,
invoice_number: values.invoice_number || undefined, invoice_number: values.invoice_number || undefined,
expense_date: values.expense_date || undefined, expense_date: values.expense_date || undefined,
notes: values.notes || undefined, notes: values.notes || undefined,
status: values.status || undefined, status: values.status || undefined,
discount: values.discount || undefined,
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
label_ids: values.labels?.map((label) => label.id) ?? [],
items: (values.items ?? []).map((item) => ({
expense_item_id: item.expense_id,
quantity: item.quantity,
rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
chart_of_account: item.chart_of_account || undefined,
description: item.description || undefined,
})),
} }
} }
@ -90,11 +144,32 @@ function mapFormToPayload(values: ExpenseFormValues) {
const mapLookupOption = (item: any) => ({ const mapLookupOption = (item: any) => ({
value: String(item.id), value: String(item.id),
label: item.name, label: item.name ?? item.title ?? `#${item.id}`,
})
const mapVendorOption = (item: any) => ({
value: String(item.id),
label: item.company_name ?? item.name ?? `#${item.id}`,
}) })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Transaction-level discount amount field ──
function TransactionDiscountField() {
const { watch } = useFormContext<ExpenseFormValues>()
const discount = watch("discount")
if (discount !== "transaction_level") return null
return (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)
}
// ── Component ── // ── Component ──
export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormProps) { export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormProps) {
@ -105,15 +180,19 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData,
queryKey: [EXPENSE_ROUTES.BY_ID, resourceId],
mapToFormValues, mapToFormValues,
}) })
const discount = form.watch("discount")
const isLineItemDiscount = discount === "line_item_level"
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ExpenseFormValues) => { mutationFn: (values: ExpenseFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values)
const promise = isEditing && resourceId const promise = (isEditing && resourceId
? api.expenses.update(resourceId, payload) ? api.expenses.update(resourceId, payload as any)
: api.expenses.create(payload) : api.expenses.create(payload as any)) as Promise<any>
toast.promise(promise, { toast.promise(promise, {
loading: isEditing ? "Updating expense..." : "Creating expense...", loading: isEditing ? "Updating expense..." : "Creating expense...",
success: isEditing ? "Expense updated successfully" : "Expense created successfully", success: isEditing ? "Expense updated successfully" : "Expense created successfully",
@ -139,29 +218,87 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
</Alert> </Alert>
)} )}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9">
<FieldGroup> <FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter expense title" required /> <RhfTextField name="title" label="Title" placeholder="Enter expense title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="invoice_number" label="Invoice Number" placeholder="INV-001" />
<RhfSelectField <RhfSelectField
name="status" name="status"
label="Status" label="Status"
placeholder="Select status" placeholder="Select status"
options={STATUS_OPTIONS} options={STATUS_OPTIONS}
/> />
<RhfTextField name="expense_date" label="Expense Date" placeholder="YYYY-MM-DD" type="date" />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <RhfSelectField
name="discount"
label="Discount Type"
options={DISCOUNT_OPTIONS}
/>
<TransactionDiscountField />
<ExpenseItemsSelectorField<ExpenseFormValues, "items">
name="items"
label="Expense Items"
triggerLabel="Add Expense Items"
showChartOfAccount
showDiscount={isLineItemDiscount}
/>
<RhfTextareaField
name="notes"
label="Notes"
rows={3}
placeholder="Additional notes"
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Expense" : "Create Expense")}
</Button>
</FieldGroup>
</div>
<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>
<RhfDateField name="expense_date" label="Expense Date" />
<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 <RhfAsyncSelectField
name="vendor" name="vendor"
label="Vendor" label="Vendor"
placeholder="Select vendor" placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]} queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()} listFn={() => api.vendors.list()}
mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})} mapOption={mapVendorOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfAsyncSelectField <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
@ -171,40 +308,46 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="job_card" name="job_card"
label="Job Card" label="Job Card"
placeholder="Select job card" placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]} queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()} listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })} mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number ?? item.estimate_number ?? `#${item.id}`,
})}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">Category</span>
<InventoryCategoryCrudDialog />
</div>
<RhfAsyncSelectField <RhfAsyncSelectField
name="category" name="category"
label="Category" label=""
placeholder="Select category" placeholder="Select category"
queryKey={[EXPENSE_ROUTES.ITEMS]} queryKey={[INVENTORY_CATEGORY_ROUTES.INDEX]}
listFn={() => api.expenses.listItems()} listFn={() => api.inventoryCategories.list()}
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div> </div>
<RhfTextField name="invoice_number" label="Invoice Number" placeholder="INV-001" /> <RhfLabelPickerField name="labels" label="Labels" />
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Expense" : "Create Expense")}
</Button>
</FieldGroup> </FieldGroup>
</CardContent>
</Card>
<div className="mt-4">
<ExpenseFormSummary />
</div>
</div>
</div>
</Rhform> </Rhform>
) )
} }

View File

@ -0,0 +1,235 @@
"use client"
import {
Calendar,
Hash,
Building2,
AlertTriangle,
CheckCircle2,
TimerIcon,
ReceiptIcon,
Wrench,
Tag,
Percent,
} 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 { useExpense } from "./expense-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",
cancelled: "destructive",
}
export function ExpenseGeneralInfo() {
const expense = useExpense()
if (!expense) return null
const vendor = expense.vendor || {}
const department = expense.department || null
const jobCard = expense.job_card || null
const category = expense.category || null
const labels = (expense as any)?.labels || []
const balanceDue = expense.balance_due ?? null
const paymentsM = expense.payments_made ?? null
const taxLabel = expense.tax?.title
? `${expense.tax.title} (${expense.tax.rate}%)`
: "Tax"
return (
<div className="grid gap-6">
{/* ── 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">
{expense.status === "paid" && <CheckCircle2 className="size-4 text-green-500" />}
{expense.status === "pending" && <TimerIcon className="size-4 text-muted-foreground" />}
{expense.status === "open" && <AlertTriangle className="size-4 text-yellow-600" />}
{expense.status === "un_paid" && <AlertTriangle className="size-4 text-destructive" />}
<Badge variant={statusVariantMap[String(expense.status ?? "")] ?? "outline"} className="text-sm px-2 py-0.5">
{formatEnum(String(expense.status ?? ""))}
</Badge>
</div>
</Card>
{/* Total */}
<Card className="flex flex-col gap-1 p-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Total</span>
<span className="mt-1 text-lg font-semibold">
{formatCurrency(expense.total ?? 0)}
</span>
{expense.sub_total != null && expense.sub_total !== expense.total && (
<span className="text-xs text-muted-foreground">Subtotal: {formatCurrency(expense.sub_total)}</span>
)}
</Card>
{/* Payments Made */}
<Card className="flex flex-col gap-1 p-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Paid</span>
<span className="mt-1 text-lg font-semibold text-green-600 dark:text-green-400">
{formatCurrency(paymentsM ?? 0)}
</span>
</Card>
{/* Balance Due */}
<Card className={cn("flex flex-col gap-1 p-4", (balanceDue ?? 0) > 0 && "border-destructive/50 bg-destructive/5")}>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Balance Due</span>
<span className={cn("mt-1 text-lg font-semibold", (balanceDue ?? 0) > 0 ? "text-destructive" : "text-green-600 dark:text-green-400")}>
{formatCurrency(balanceDue ?? 0)}
</span>
</Card>
</div>
{/* ── 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 as any) || "—"}</span>
</Card>
{/* ── Expense Details ── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ReceiptIcon className="size-4" />
Expense Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<InfoItem icon={Hash} label="Invoice Number" value={expense.invoice_number} />
<InfoItem icon={Calendar} label="Expense Date" value={formatDate(expense.expense_date)} />
<InfoItem icon={Building2} label="Department" value={department?.name} />
{expense.discount && expense.discount !== "no" && (
<InfoItem icon={Percent} label="Discount Type" value={formatEnum(expense.discount)} />
)}
{expense.discount_amount_major != null && expense.discount_amount_major > 0 && (
<InfoItem icon={Percent} label="Discount" value={formatCurrency(expense.discount_amount_major)} />
)}
{expense.tax_amount != null && expense.tax_amount > 0 && (
<InfoItem icon={Percent} label={taxLabel} value={formatCurrency(expense.tax_amount)} />
)}
</div>
</CardContent>
</Card>
{/* ── Job Card & Category ── */}
{(jobCard || category) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="size-4" />
Related Information
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{jobCard && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<Wrench className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground font-medium">Job Card</span>
<span className="text-sm font-semibold">{jobCard.order_number || "—"}</span>
<span className="text-xs text-muted-foreground">{jobCard.title || ""}</span>
</div>
</div>
)}
{category && (
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
<Tag className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground font-medium">Category</span>
<span className="text-sm font-semibold">{category.title || "—"}</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* ── Labels ── */}
{labels && labels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Tag className="size-4" />
Labels
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{labels.map((label: any) => (
<Badge
key={label.id}
style={{
backgroundColor: label.color_code + "20",
borderColor: label.color_code,
color: label.color_code,
}}
variant="outline"
>
{label.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* ── Notes ── */}
{expense.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{expense.notes}</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,133 @@
"use client"
import { ShoppingCartIcon } 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 } from "@/shared/utils/formatters"
type ExpenseItem = {
id?: number
expense_id?: number
expense_item_id?: number
quantity?: number | string
rate?: string | number
description?: string | null
expense_item?: {
id?: number
item_name?: string
name?: string
item_code?: string
sku?: string
}
}
type ExpenseItemsSectionProps = {
items?: ExpenseItem[]
discountType?: string | null
subTotal?: number | null
discountAmount?: number | null
taxAmount?: number | null
total?: number | null
taxLabel?: string | null
}
export function ExpenseItemsSection({
items,
discountType,
subTotal,
discountAmount,
taxAmount,
total,
taxLabel,
}: ExpenseItemsSectionProps) {
if (!items || items.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingCartIcon className="size-4" />
Expense Items ({items.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Item</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right w-20">Qty</TableHead>
<TableHead className="text-right w-28">Rate</TableHead>
<TableHead className="text-right w-28">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const qty = Number(item.quantity || 0)
const rate = Number(item.rate || 0)
const amount = qty * rate
const name = item.expense_item?.item_name || item.expense_item?.name || "—"
return (
<TableRow key={item.id}>
<TableCell className="font-medium">
<div>{name}</div>
{(item.expense_item?.sku || item.expense_item?.item_code) && (
<div className="text-xs text-muted-foreground font-mono">
{item.expense_item?.sku || item.expense_item?.item_code}
</div>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{item.description || "—"}
</TableCell>
<TableCell className="text-right text-sm">{qty.toLocaleString()}</TableCell>
<TableCell className="text-right text-sm">{formatCurrency(rate)}</TableCell>
<TableCell className="text-right font-semibold">{formatCurrency(amount)}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* ── Totals summary ── */}
<div className="mt-4 flex justify-end">
<div className="w-64 space-y-1 text-sm">
{subTotal != null && (
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium">{formatCurrency(subTotal)}</span>
</div>
)}
{discountAmount != null && discountAmount > 0 && discountType !== "no" && (
<div className="flex justify-between text-destructive">
<span>Discount</span>
<span> {formatCurrency(discountAmount)}</span>
</div>
)}
{taxAmount != null && taxAmount > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">{taxLabel || "Tax"}</span>
<span className="font-medium">{formatCurrency(taxAmount)}</span>
</div>
)}
{total != null && (
<div className="flex justify-between border-t pt-2 text-base font-semibold">
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -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 { useExpense } from "./expense-context"
import { formatDate } from "@/shared/utils/formatters"
import { getFullName } from "@/shared/utils/getFullName"
import { toRelation } from "@/shared/lib/utils"
export function ExpensePaymentsSection() {
const expense = useExpense()
const router = useRouter()
return (
<CrudResource<any>
extraParams={{ expense_id: expense?.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={{
amount: (expense as any)?.amount,
vendor: toRelation(expense?.vendor?.id, getFullName(expense?.vendor as any)),
payment_for: "expense",
}}
expenseId={expense?.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(),
]}
/>
)
}

View File

@ -4,12 +4,29 @@ const relationFieldSchema = z
.object({ value: z.string(), label: z.string() }) .object({ value: z.string(), label: z.string() })
.nullable() .nullable()
const labelItemSchema = z.object({
id: z.number(),
title: z.string(),
color_code: z.string(),
})
const expenseLineItemSchema = z.object({
expense_id: z.number(),
title: z.string(),
quantity: z.number().min(1),
rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(),
})
const expenseFormSchema = z.object({ const expenseFormSchema = z.object({
// ── Relations ── // ── Relations ──
job_card: relationFieldSchema, job_card: relationFieldSchema,
category: relationFieldSchema, category: relationFieldSchema,
vendor: relationFieldSchema, vendor: relationFieldSchema,
department: relationFieldSchema, department: relationFieldSchema,
tax: relationFieldSchema,
// ── Basic info ── // ── Basic info ──
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
@ -17,6 +34,13 @@ const expenseFormSchema = z.object({
expense_date: z.string().optional(), expense_date: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
status: z.string().optional(), status: z.string().optional(),
// ── Discount / Tax ──
discount: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
labels: z.array(labelItemSchema).optional(),
items: z.array(expenseLineItemSchema).optional(),
}) })
type ExpenseFormValues = z.infer<typeof expenseFormSchema> type ExpenseFormValues = z.infer<typeof expenseFormSchema>

View File

@ -2,6 +2,7 @@
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@ -9,7 +10,10 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu" } from "@/shared/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Ellipsis, Pencil, Trash2 } from "lucide-react" import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { InvoiceEditForm } from "./invoice-edit-form"
import { useInvoice } from "./invoice-context"
type InvoiceActionsProps = { type InvoiceActionsProps = {
invoiceId: string invoiceId: string
@ -18,9 +22,11 @@ type InvoiceActionsProps = {
export function InvoiceActions({ invoiceId }: InvoiceActionsProps) { export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
const api = useAuthApi() const api = useAuthApi()
const router = useRouter() const router = useRouter()
const [isEditOpen, setIsEditOpen] = useState(false)
const handleEdit = () => { const invoice = useInvoice()
router.push(`/sales/invoice/${invoiceId}/edit`) const handleEditSuccess = () => {
setIsEditOpen(false)
router.refresh()
} }
const handleDelete = async () => { const handleDelete = async () => {
@ -29,6 +35,7 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
} }
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
@ -36,15 +43,31 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}> {/* <DropdownMenuItem onClick={() => setIsEditOpen(true)}>
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem> */}
<DropdownMenuItem variant="destructive" onClick={handleDelete}> <DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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> */}
</>
) )
} }

View File

@ -1,10 +1,22 @@
"use client" "use client"
import { ApiResponse } from "@garage/api"
import { createContext, useContext } from "react" import { createContext, useContext } from "react"
type InvoiceContextValue = { type InvoiceContextValue = {
id: string id: number | string
label: 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) const InvoiceContext = createContext<InvoiceContextValue | null>(null)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -5,6 +5,7 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert" import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field" import { FieldGroup } from "@/shared/components/ui/field"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { import {
Rhform, Rhform,
RhfTextField, RhfTextField,
@ -29,19 +30,19 @@ import {
import { import {
INVOICE_ROUTES, INVOICE_ROUTES,
DEPARTMENT_ROUTES, DEPARTMENT_ROUTES,
ESTIMATE_ROUTES,
PAYMENT_TERM_ROUTES, PAYMENT_TERM_ROUTES,
INVOICE_SEQUENCE_ROUTES, INVOICE_SEQUENCE_ROUTES,
PAYMENT_MODE_ROUTES, TAX_ROUTES,
CUSTOMER_ROUTES,
InvoiceStatus, InvoiceStatus,
InvoiceDiscount, InvoiceDiscount,
} from "@garage/api" } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" 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 { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { PartsSelectorField } from "@/modules/parts/parts-selector-field" import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { ServicesSelectorField } from "@/modules/services/services-selector-field" import { ServicesSelectorField } from "@/modules/services/services-selector-field"
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field" import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
import { InvoiceFormSummary } from "./invoice-form-summary"
// ── Constants ── // ── Constants ──
@ -78,12 +79,14 @@ const DEFAULT_VALUES: InvoiceFormValues = {
invoice_to: null, invoice_to: null,
invoice_number: "", invoice_number: "",
invoice_title: "", invoice_title: "",
invoice_date: "", invoice_date: new Date().toISOString().split("T")[0],
due_date: "", due_date: "",
status: "draft", status: "draft",
kms_in: undefined, kms_in: undefined,
has_insurance: false, has_insurance: false,
discount: "no", discount: "no",
discount_amount: undefined,
tax: null,
deposit_to: "", deposit_to: "",
notes: "", notes: "",
terms_and_conditions: "", terms_and_conditions: "",
@ -116,6 +119,8 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
kms_in: d.kms_in ? Number(d.kms_in) : undefined, kms_in: d.kms_in ? Number(d.kms_in) : undefined,
has_insurance: d.has_insurance ?? false, has_insurance: d.has_insurance ?? false,
discount: d.discount || "no", 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 || "", deposit_to: d.deposit_to || "",
notes: d.notes || "", notes: d.notes || "",
terms_and_conditions: d.terms_and_conditions || "", terms_and_conditions: d.terms_and_conditions || "",
@ -124,6 +129,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
title: p.part?.title ?? p.title ?? "", title: p.part?.title ?? p.title ?? "",
quantity: Number(p.quantity) || 1, quantity: Number(p.quantity) || 1,
rate: Number(p.rate) || 0, rate: Number(p.rate) || 0,
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
description: p.description ?? "", description: p.description ?? "",
})), })),
services: (d.invoice_services ?? d.services ?? []).map((s: any) => ({ 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 ?? "", title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
quantity: Number(s.quantity) || 1, quantity: Number(s.quantity) || 1,
rate: Number(s.rate) || 0, rate: Number(s.rate) || 0,
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
description: s.description ?? "", description: s.description ?? "",
})), })),
expense_items: (d.invoice_expenses ?? d.expenses ?? []).map((e: any) => ({ 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 ?? "", title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
quantity: Number(e.quantity) || 1, quantity: Number(e.quantity) || 1,
rate: Number(e.rate) || 0, rate: Number(e.rate) || 0,
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
description: e.description ?? "", description: e.description ?? "",
})), })),
} }
@ -163,6 +171,8 @@ function mapFormToPayload(values: InvoiceFormValues) {
kms_in: values.kms_in || undefined, kms_in: values.kms_in || undefined,
has_insurance: values.has_insurance, has_insurance: values.has_insurance,
discount: values.discount || undefined, 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, deposit_to: values.deposit_to || undefined,
notes: values.notes || undefined, notes: values.notes || undefined,
terms_and_conditions: values.terms_and_conditions || undefined, terms_and_conditions: values.terms_and_conditions || undefined,
@ -170,18 +180,21 @@ function mapFormToPayload(values: InvoiceFormValues) {
part_id: item.part_id, part_id: item.part_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || undefined, description: item.description || undefined,
})), })),
services: (values.services ?? []).map((item) => ({ services: (values.services ?? []).map((item) => ({
service_id: item.service_id, service_id: item.service_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || undefined, description: item.description || undefined,
})), })),
expenses: (values.expense_items ?? []).map((item) => ({ expenses: (values.expense_items ?? []).map((item) => ({
expense_id: item.expense_id, expense_id: item.expense_id,
quantity: item.quantity, quantity: item.quantity,
rate: item.rate, rate: item.rate,
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
description: item.description || 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 ── // ── Component ──
export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) { export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) {
@ -227,6 +259,9 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
mapToFormValues, mapToFormValues,
}) })
const discount = form.watch("discount")
const isLineItemDiscount = discount === "line_item_level"
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InvoiceFormValues) => { mutationFn: (values: InvoiceFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values)
@ -258,6 +293,10 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
</Alert> </Alert>
)} )}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
{/* ── Main column (8/12) ── */}
<div className="lg:col-span-9">
<FieldGroup> <FieldGroup>
<RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required /> <RhfTextField name="subject" label="Subject" placeholder="Invoice subject" required />
@ -268,25 +307,56 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} /> <RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} /> <RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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>
{/* ── 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="invoice_date" label="Invoice Date" />
<RhfDateField name="due_date" label="Due Date" /> <RhfDateField name="due_date" label="Due Date" />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" /> <RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
<RhfVehicleSelectField name="vehicle" /> <RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <RhfAsyncSelectField
<RhfCustomerSelectField<InvoiceFormValues, "invoice_to"> name="tax"
name="invoice_to" label="Tax"
label="Invoice To" placeholder="Select tax rate"
placeholder="Select billing contact..." 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 <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
@ -296,21 +366,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...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 <RhfAsyncSelectField
name="payment_terms" name="payment_terms"
label="Payment Terms" label="Payment Terms"
@ -320,12 +376,15 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium">Invoice Sequence</span>
<InvoiceSequenceCrudDialog />
</div>
<RhfAsyncSelectField <RhfAsyncSelectField
name="invoice_sequence" name="invoice_sequence"
label="Invoice Sequence" label=""
placeholder="Select sequence" placeholder="Select sequence"
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]} queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
listFn={() => api.invoiceSequences.list()} listFn={() => api.invoiceSequences.list()}
@ -335,39 +394,20 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
})} })}
{...STORE_OBJECT} {...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> </div>
<RhfCheckboxField name="has_insurance" label="Has Insurance" /> <RhfCheckboxField name="has_insurance" label="Has Insurance" />
<InsurerField /> <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> </FieldGroup>
</CardContent>
</Card>
<div className="mt-4">
<InvoiceFormSummary />
</div>
</div>
</div>
</Rhform> </Rhform>
) )
} }

View File

@ -1,3 +1,4 @@
"use client"
import { import {
FileText, FileText,
Calendar, Calendar,
@ -5,11 +6,12 @@ import {
Users, Users,
Car, Car,
Building2, Building2,
CircleDollarSign,
Clock, Clock,
Mail, Mail,
Phone, Phone,
DollarSign, AlertTriangle,
CheckCircle2,
TimerIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Card, Card,
@ -19,45 +21,9 @@ import {
} from "@/shared/components/ui/card" } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator" import { Separator } from "@/shared/components/ui/separator"
import { cn } from "@/shared/lib/utils"
import { formatDate, formatCurrency, formatEnum, formatNumber } from "@/shared/utils/formatters" import { formatDate, formatCurrency, formatEnum, formatNumber } from "@/shared/utils/formatters"
import { useInvoice } from "./invoice-context"
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
}
function InfoItem({ function InfoItem({
icon: Icon, icon: Icon,
@ -83,7 +49,7 @@ function InfoItem({
) )
} }
const statusColorMap: Record<string, string> = { const statusVariantMap: Record<string, "secondary" | "default" | "destructive" | "outline"> = {
draft: "secondary", draft: "secondary",
open: "default", open: "default",
paid: "default", paid: "default",
@ -91,56 +57,120 @@ const statusColorMap: Record<string, string> = {
void: "outline", 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 customer = invoice.customer || {}
const vehicle = invoice.vehicle || {} const vehicle = invoice.vehicle || {}
const insurer = invoice.insurer || {} 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 ( return (
<div className="grid gap-6"> <div className="grid gap-6">
{/* Invoice Details */}
<Card> {/* ── Summary Hero ── */}
<CardHeader> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<CardTitle className="flex items-center gap-2"> {/* Status */}
<FileText className="size-4" /> <Card className="flex flex-col gap-1 p-4">
Invoice Details <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Status</span>
</CardTitle> <div className="mt-1 flex items-center gap-2">
</CardHeader> {invoice.status === "paid" && <CheckCircle2 className="size-4 text-green-500" />}
<CardContent className="grid gap-4"> {invoice.status === "overdue" && <AlertTriangle className="size-4 text-destructive" />}
<div className="flex flex-wrap items-center gap-2"> {(invoice.status === "draft" || invoice.status === "open") && <TimerIcon className="size-4 text-muted-foreground" />}
{invoice.subject && ( <Badge variant={statusVariantMap[String(invoice.status ?? "")] ?? "outline"} className="text-sm px-2 py-0.5">
<Badge variant="secondary">{invoice.subject}</Badge> {formatEnum(String(invoice.status ?? ""))}
)}
{invoice.status && (
<Badge variant={statusColorMap[invoice.status] as any ?? "outline"}>
{formatEnum(invoice.status)}
</Badge> </Badge>
</div>
{invoice.invoice_number && (
<span className="mt-1 text-xs text-muted-foreground">{invoice.invoice_number}</span>
)} )}
</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> </Card>
{/* Customer & Vehicle Information */} {/* 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"> <div className="grid gap-6 md:grid-cols-2">
{/* Customer Details */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -155,21 +185,9 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
label="Customer Name" label="Customer Name"
value={customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : invoice.customer_name} value={customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : invoice.customer_name}
/> />
<InfoItem <InfoItem icon={Mail} label="Email" value={customer.email} />
icon={Mail} <InfoItem icon={Phone} label="Phone" value={customer.phone} />
label="Email" <InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
value={customer.email}
/>
<InfoItem
icon={Phone}
label="Phone"
value={customer.phone}
/>
<InfoItem
icon={Phone}
label="Alternate Phone"
value={customer.alternate_phone}
/>
</div> </div>
{customer.address_line_1 && ( {customer.address_line_1 && (
<> <>
@ -180,8 +198,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
{customer.address_line_1} {customer.address_line_1}
{customer.address_line_2 ? `, ${customer.address_line_2}` : ""} {customer.address_line_2 ? `, ${customer.address_line_2}` : ""}
<br /> <br />
{customer.city ? `${customer.city}` : ""} {customer.city ?? ""}{customer.zip_code ? `, ${customer.zip_code}` : ""}
{customer.zip_code ? `, ${customer.zip_code}` : ""}
</p> </p>
</div> </div>
</> </>
@ -189,7 +206,6 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Vehicle Details */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -204,107 +220,49 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
label="Vehicle" label="Vehicle"
value={vehicle.make && vehicle.model ? `${vehicle.make} ${vehicle.model}` : invoice.vehicle_name} value={vehicle.make && vehicle.model ? `${vehicle.make} ${vehicle.model}` : invoice.vehicle_name}
/> />
<InfoItem <InfoItem icon={Hash} label="License Plate" value={vehicle.license_plate} />
icon={Hash} <InfoItem icon={Hash} label="VIN" value={vehicle.vin_number} />
label="License Plate" <InfoItem icon={Hash} label="Engine Number" value={vehicle.engine_number} />
value={vehicle.license_plate}
/>
<InfoItem
icon={Hash}
label="VIN"
value={vehicle.vin_number}
/>
<InfoItem
icon={Hash}
label="Engine Number"
value={vehicle.engine_number}
/>
</div> </div>
{vehicle.mileage && ( {vehicle.mileage && (
<> <>
<Separator /> <Separator />
<InfoItem <InfoItem icon={Clock} label="Mileage" value={formatNumber(vehicle.mileage)} />
icon={Clock}
label="Mileage"
value={formatNumber(vehicle.mileage)}
/>
</> </>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Payment & Insurance Information */} {/* ── Invoice Meta ── */}
<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>
{/* Insurance & Additional Info */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> <FileText className="size-4" />
Additional Information Invoice Details
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4"> <CardContent>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-3">
<InfoItem <InfoItem icon={Hash} label="Invoice Number" value={invoice.invoice_number} />
icon={Building2} <InfoItem icon={Calendar} label="Invoice Date" value={formatDate(invoice.invoice_date)} />
label="Department" <InfoItem icon={Calendar} label="Due Date" value={formatDate(invoice.due_date)} />
value={invoice.department_name} <InfoItem icon={Building2} label="Department" value={department?.name} />
/> <InfoItem icon={Hash} label="Has Insurance" value={invoice.has_insurance ? "Yes" : "No"} />
<InfoItem
icon={Hash}
label="Has Insurance"
value={invoice.has_insurance ? "Yes" : "No"}
/>
{invoice.has_insurance && insurer.id && ( {invoice.has_insurance && insurer.id && (
<InfoItem <InfoItem icon={Users} label="Insurer" value={insurer.name} />
icon={Users}
label="Insurer"
value={insurer.name}
/>
)}
{invoice.kms_in && (
<InfoItem
icon={Clock}
label="KMs In"
value={formatNumber(invoice.kms_in)}
/>
)} )}
{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> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Notes & Terms */} {/* ── Notes & Terms ── */}
{(invoice.notes || invoice.terms_and_conditions) && ( {(invoice.notes || invoice.terms_and_conditions) && (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{invoice.notes && ( {invoice.notes && (
@ -313,7 +271,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
<CardTitle className="text-base">Notes</CardTitle> <CardTitle className="text-base">Notes</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm whitespace-pre-wrap">{invoice.notes}</p> <p className="whitespace-pre-wrap text-sm">{invoice.notes}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -323,13 +281,12 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
<CardTitle className="text-base">Terms & Conditions</CardTitle> <CardTitle className="text-base">Terms & Conditions</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <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> </CardContent>
</Card> </Card>
)} )}
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}
/>
)}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -9,6 +9,7 @@ const invoicePartItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -17,6 +18,7 @@ const invoiceServiceItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -25,6 +27,7 @@ const invoiceExpenseItemSchema = z.object({
title: z.string(), title: z.string(),
quantity: z.number().min(1), quantity: z.number().min(1),
rate: z.number().min(0), rate: z.number().min(0),
discount_amount: z.number().min(0).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -52,6 +55,8 @@ const invoiceFormSchema = z.object({
kms_in: z.coerce.number().optional(), kms_in: z.coerce.number().optional(),
has_insurance: z.boolean().default(false), has_insurance: z.boolean().default(false),
discount: z.string().optional(), discount: z.string().optional(),
discount_amount: z.coerce.number().min(0).optional(),
tax: relationFieldSchema,
deposit_to: z.string().optional(), deposit_to: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
terms_and_conditions: z.string().optional(), terms_and_conditions: z.string().optional(),

View File

@ -1,17 +1,26 @@
"use client" "use client"
import { confirm } from '@/shared/components/confirm-dialog'; import { confirm } from '@/shared/components/confirm-dialog';
import { api } from '@garage/api';
import { useRouter } from 'next/dist/client/components/navigation'; import { useRouter } from 'next/dist/client/components/navigation';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
import { Button } from '@/shared/components/ui/button' import { Button } from '@/shared/components/ui/button'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react'; import { Ellipsis, FileText, Pencil, Printer, Trash2 } from 'lucide-react';
import { useDocumentPrint } from '@/shared/hooks/use-document-print'; import { useDocumentPrint } from '@/shared/hooks/use-document-print';
import { useJobCard } from './job-card-context';
import { useState } from 'react';
import { useAuthApi } from '@/shared/useApi';
// TODO: setting a sales person not working
// TODO: unable to set a Primary technician for the job card. Need to investigate and fix it.
export default function JobCardDropdown({ id }: { id: string }) { export default function JobCardDropdown({ id }: { id: string }) {
const api = useAuthApi()
const router = useRouter(); const router = useRouter();
const { print, isPrinting } = useDocumentPrint() const { print, isPrinting } = useDocumentPrint()
const jobCard = useJobCard()
const [isConverting, setIsConverting] = useState(false)
const handleEdit = () => { const handleEdit = () => {
router.push(`/sales/job-cards/${id}/edit`) router.push(`/sales/job-cards/${id}/edit`)
@ -21,6 +30,35 @@ export default function JobCardDropdown({ id }: { id: string }) {
print("job_card", id, "print") print("job_card", id, "print")
} }
const handleConvertToInvoice = async () => {
const confirmed = await confirm({
title: "Convert to Invoice",
description: "This will create a new invoice from this job card. Do you want to continue?",
confirmLabel: "Convert",
})
if (!confirmed) return
setIsConverting(true)
try {
const res = await api.jobCards.convertToInvoice(id, {}) as any
const invoiceId = res?.data?.id
toast.success("Job card converted to invoice successfully")
if (invoiceId) {
router.push(`/sales/invoice/${invoiceId}`)
}
} catch (err: any) {
const conflictId = err?.response?.data?.data?.invoice_id ?? err?.data?.data?.invoice_id
if (conflictId) {
toast.info("An invoice already exists for this job card.")
router.push(`/sales/invoice/${conflictId}`)
} else {
toast.error("Failed to convert job card to invoice")
}
} finally {
setIsConverting(false)
}
}
const handleDelete = async () => { const handleDelete = async () => {
const confirmed = await confirm({ const confirmed = await confirm({
title: "Delete Job Card", title: "Delete Job Card",
@ -57,6 +95,15 @@ export default function JobCardDropdown({ id }: { id: string }) {
<Printer className="size-4" /> <Printer className="size-4" />
{isPrinting ? "Printing..." : "Print"} {isPrinting ? "Printing..." : "Print"}
</DropdownMenuItem> </DropdownMenuItem>
{jobCard?.status !== "draft" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleConvertToInvoice} disabled={isConverting}>
<FileText className="size-4" />
{isConverting ? "Converting..." : "Convert to Invoice"}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}> <DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />

View File

@ -0,0 +1,280 @@
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod"
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,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardExpenseItemFormSchema = z.object({
expense_item: relationFieldSchema,
department: relationFieldSchema.optional(),
tax: relationFieldSchema.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"),
discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(),
})
type JobCardExpenseItemFormValues = z.infer<typeof jobCardExpenseItemFormSchema>
// ── Props ──
export type JobCardExpenseItemFormProps = {
jobCardId: string
jobCardExpenseItemId?: number | null
initialData?: unknown
onSuccess?: () => void
onCancel?: () => void
}
const DEFAULT_VALUES: JobCardExpenseItemFormValues = {
expense_item: null,
department: null,
tax: null,
quantity: 1,
rate: 0,
discount_amount: undefined,
chart_of_account: "",
description: "",
}
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): JobCardExpenseItemFormValues {
const d = (data as any) ?? {}
return {
expense_item: d.expense_item
? { value: String(d.expense_item.id), label: d.expense_item.item_name ?? String(d.expense_item.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "",
}
}
// ── Component ──
export function JobCardExpenseItemForm({
jobCardId,
jobCardExpenseItemId,
initialData,
onSuccess,
onCancel,
}: JobCardExpenseItemFormProps) {
const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardExpenseItemId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardExpenseItemFormValues>({
resolver: zodResolver(jobCardExpenseItemFormSchema) as any,
defaultValues: initialData
? mapToFormValues(initialData)
: DEFAULT_VALUES,
})
const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false)
async function handleSubmit(values: JobCardExpenseItemFormValues) {
setError(null)
setIsPending(true)
try {
if (isEditing && jobCardExpenseItemId) {
await toast.promise(
api.jobCards.updateExpenseItem(jobCardId, {
job_card_expense_item_id: jobCardExpenseItemId,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined,
}),
{
loading: "Updating expense item...",
success: "Expense item updated successfully",
error: "Failed to update expense item",
}
)
} else {
await toast.promise(
api.jobCards.addExpenseItem(jobCardId, {
expense_item_id: values.expense_item ? Number(values.expense_item.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity,
rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined,
}),
{
loading: "Adding expense item...",
success: "Expense item added successfully",
error: "Failed to add expense item",
}
)
}
form.reset()
onSuccess?.()
} catch (err: any) {
setError(err?.message ?? "An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>
{isEditing ? "Failed to update expense item" : "Failed to add expense item"}
</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<RhfAsyncSelectField
name="expense_item"
label="Expense Item"
placeholder="Select expense item"
required
queryKey={["expense-items"]}
listFn={() => api.expenses.listItems()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.item_name ?? item.title ?? String(item.id),
})}
{...STORE_OBJECT}
/>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="quantity"
label="Quantity"
type="number"
placeholder="1"
required
/>
<RhfTextField
name="rate"
label="Rate"
type="number"
placeholder="0.00"
required
/>
</div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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 ?? String(item.id),
})}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...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}
/>
</div>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="e.g. COA-400"
/>
</>
)}
<RhfTextareaField
name="description"
label="Description"
placeholder="Optional notes"
/>
</FieldGroup>
<div className="flex justify-end gap-2 pt-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? (
isEditing ? "Saving..." : "Adding..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Expense Item
</>
)}
</Button>
</div>
</Rhform>
)
}

View File

@ -26,8 +26,9 @@ import {
type JobCardFormValues, type JobCardFormValues,
FUEL_LEVEL_OPTIONS, FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS, JOB_CARD_STATUS_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
} from "./job-card.schema" } from "./job-card.schema"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api" import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES, TAX_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field" import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field" import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field" import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
@ -55,6 +56,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
sales_person: null, sales_person: null,
insurance_type: null, insurance_type: null,
insurer: null, insurer: null,
tax: null,
order_number: "", order_number: "",
estimate_number: "", estimate_number: "",
status: "check_in", status: "check_in",
@ -104,6 +106,7 @@ function mapToFormValues(data: unknown): JobCardFormValues {
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined), sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name), insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined), insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
tax: toRelation(d.tax_id, d.tax ? `${d.tax.title} (${d.tax.rate}%)` : undefined),
order_number: d.order_number || "", order_number: d.order_number || "",
estimate_number: d.estimate_number || "", estimate_number: d.estimate_number || "",
status: d.status || "draft", status: d.status || "draft",
@ -148,6 +151,7 @@ function mapFormToPayload(values: JobCardFormValues) {
sales_person_id: toId(values.sales_person), sales_person_id: toId(values.sales_person),
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null, insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
insurer_id: values.insurer ? String(toId(values.insurer)) : null, insurer_id: values.insurer ? String(toId(values.insurer)) : null,
tax_id: toId(values.tax) ?? undefined,
estimate_number: values.estimate_number || undefined, estimate_number: values.estimate_number || undefined,
order_number: values.order_number || undefined, order_number: values.order_number || undefined,
status: values.status || undefined, status: values.status || undefined,
@ -347,6 +351,24 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
placeholder="Select status" placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS} options={JOB_CARD_STATUS_OPTIONS}
/> />
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_TYPE_OPTIONS}
/>
<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}
/>
<RhfCustomerRemarksField name="customer_remarks" /> <RhfCustomerRemarksField name="customer_remarks" />
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} /> <RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
<RhfCheckboxField <RhfCheckboxField

View File

@ -29,10 +29,9 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page" import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received" import JobCardPaymentsReceived from "./job-card-payments-received"
import { formatDate } from "@/shared/utils/formatters" import { formatDate } from "@/shared/utils/formatters"
import type { JobCardShowData } from "@garage/api"
type JobCard = JobCardShowData
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
function InfoItem({ function InfoItem({

View File

@ -1,4 +1,4 @@
"use client" "use client"
import React from "react" import React from "react"
import { AlertTriangle, Plus, Save } from "lucide-react" import { AlertTriangle, Plus, Save } from "lucide-react"
@ -18,26 +18,27 @@ import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ── // ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardPartFormSchema = z.object({ const jobCardPartFormSchema = z.object({
part: z part: relationFieldSchema,
.object({ value: z.string(), label: z.string() }) department: relationFieldSchema.optional(),
.nullable(), tax: relationFieldSchema.optional(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"), quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"), rate: z.coerce.number().min(0, "Rate is required"),
tax: z.string().optional(), discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema> type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
// ── Props ── // ── Props ──
export type JobCardPartFormProps = { export type JobCardPartFormProps = {
jobCardId: string jobCardId: string
@ -50,9 +51,11 @@ export type JobCardPartFormProps = {
const DEFAULT_VALUES: JobCardPartFormValues = { const DEFAULT_VALUES: JobCardPartFormValues = {
part: null, part: null,
department: null, department: null,
tax: null,
quantity: 1, quantity: 1,
rate: 0, rate: 0,
tax: "", discount_amount: undefined,
chart_of_account: "",
description: "", description: "",
} }
@ -67,14 +70,18 @@ function mapToFormValues(data: unknown): JobCardPartFormValues {
department: d.department department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) } ? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null, : null,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null,
quantity: d.quantity ?? 1, quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0, rate: d.rate != null ? Number(d.rate) : 0,
tax: d.tax ?? "", discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "", description: d.description ?? "",
} }
} }
// ── Component ── // ── Component ──
export function JobCardPartForm({ export function JobCardPartForm({
jobCardId, jobCardId,
@ -84,7 +91,9 @@ export function JobCardPartForm({
onCancel, onCancel,
}: JobCardPartFormProps) { }: JobCardPartFormProps) {
const api = useAuthApi() const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardPartId const isEditing = !!jobCardPartId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardPartFormValues>({ const form = useForm<JobCardPartFormValues>({
resolver: zodResolver(jobCardPartFormSchema) as any, resolver: zodResolver(jobCardPartFormSchema) as any,
@ -106,6 +115,7 @@ export function JobCardPartForm({
job_card_part_id: jobCardPartId, job_card_part_id: jobCardPartId,
quantity: values.quantity, quantity: values.quantity,
rate: values.rate, rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined, description: values.description || undefined,
}), }),
{ {
@ -119,9 +129,11 @@ export function JobCardPartForm({
api.jobCards.addPart(jobCardId, { api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined, part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined, department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
quantity: values.quantity, quantity: values.quantity,
rate: values.rate, rate: values.rate,
tax: values.tax || undefined, discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined, description: values.description || undefined,
}), }),
{ {
@ -186,13 +198,23 @@ export function JobCardPartForm({
/> />
</div> </div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && ( {!isEditing && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
placeholder="Select department" placeholder="Select department"
queryKey={["departments"]} queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()} listFn={() => api.departments.list()}
mapOption={(item: any) => ({ mapOption={(item: any) => ({
value: String(item.id), value: String(item.id),
@ -202,12 +224,26 @@ export function JobCardPartForm({
createLabel="Department" createLabel="Department"
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfTextField <RhfAsyncSelectField
name="tax" name="tax"
label="Tax" label="Tax"
placeholder="e.g. 5%" 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}
/> />
</div> </div>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="e.g. COA-300"
/>
</>
)} )}
<RhfTextareaField <RhfTextareaField

View File

@ -1,7 +1,7 @@
"use client" "use client"
import React from "react" import React from "react"
import { AlertTriangle } from "lucide-react" import { AlertTriangle, Plus, Save } from "lucide-react"
import { z } from "zod" import { z } from "zod"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@ -19,28 +19,24 @@ import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { RateType } from "@garage/api" import { RateType, DEPARTMENT_ROUTES, TAX_ROUTES } from "@garage/api"
import { useJobCard } from "./job-card-context"
// ── Schema ── // ── Schema ──
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
const jobCardServiceFormSchema = z.object({ const jobCardServiceFormSchema = z.object({
service: z service: relationFieldSchema,
.object({ value: z.string(), label: z.string() }) department: z.object({ value: z.string(), label: z.string() }),
.nullable(), tax: relationFieldSchema.optional(),
department: z
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
rate_type: z.string().optional(), rate_type: z.string().optional(),
labor_rate: z labor_rate: relationFieldSchema.optional(),
.object({ value: z.string(), label: z.string() })
.nullable()
.optional(),
quantity: z.coerce.number().min(1, "Quantity is required"), quantity: z.coerce.number().min(1, "Quantity is required"),
rate: z.coerce.number().min(0, "Rate is required"), rate: z.coerce.number().min(0, "Rate is required"),
working_hours: z.coerce.number().min(0).optional(), working_hours: z.coerce.number().min(0).optional(),
labor_hours: z.coerce.number().min(0).optional(), labor_hours: z.coerce.number().min(0).optional(),
tax: z.string().optional(), discount_amount: z.coerce.number().min(0).optional(),
chart_of_account: z.string().optional(), chart_of_account: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
@ -59,14 +55,15 @@ export type JobCardServiceFormProps = {
const DEFAULT_VALUES: JobCardServiceFormValues = { const DEFAULT_VALUES: JobCardServiceFormValues = {
service: null, service: null,
department: null, department: undefined as any,
tax: null,
rate_type: "flat_rate", rate_type: "flat_rate",
labor_rate: null, labor_rate: null,
quantity: 1, quantity: 1,
rate: 0, rate: 0,
working_hours: 0, working_hours: 0,
labor_hours: 0, labor_hours: 0,
tax: "", discount_amount: undefined,
chart_of_account: "", chart_of_account: "",
description: "", description: "",
} }
@ -81,6 +78,9 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
: null, : null,
department: d.department department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) } ? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: undefined as any,
tax: d.tax_id != null
? { value: String(d.tax_id), label: d.tax ? `${d.tax.title} (${d.tax.rate}%)` : String(d.tax_id) }
: null, : null,
rate_type: d.rate_type ?? "flat_rate", rate_type: d.rate_type ?? "flat_rate",
labor_rate: d.labor_rate labor_rate: d.labor_rate
@ -90,7 +90,7 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
rate: d.rate != null ? Number(d.rate) : 0, rate: d.rate != null ? Number(d.rate) : 0,
working_hours: d.working_hours != null ? Number(d.working_hours) : 0, working_hours: d.working_hours != null ? Number(d.working_hours) : 0,
labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0, labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0,
tax: d.tax ?? "", discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
chart_of_account: d.chart_of_account ?? "", chart_of_account: d.chart_of_account ?? "",
description: d.description ?? "", description: d.description ?? "",
} }
@ -111,7 +111,9 @@ export function JobCardServiceForm({
onCancel, onCancel,
}: JobCardServiceFormProps) { }: JobCardServiceFormProps) {
const api = useAuthApi() const api = useAuthApi()
const jobCard = useJobCard()
const isEditing = !!jobCardServiceId const isEditing = !!jobCardServiceId
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
const form = useForm<JobCardServiceFormValues>({ const form = useForm<JobCardServiceFormValues>({
resolver: zodResolver(jobCardServiceFormSchema) as any, resolver: zodResolver(jobCardServiceFormSchema) as any,
@ -120,6 +122,9 @@ export function JobCardServiceForm({
: DEFAULT_VALUES, : DEFAULT_VALUES,
}) })
const rateType = form.watch("rate_type")
const isHourly = rateType === "hourly"
const [error, setError] = React.useState<string | null>(null) const [error, setError] = React.useState<string | null>(null)
const [isPending, setIsPending] = React.useState(false) const [isPending, setIsPending] = React.useState(false)
@ -133,6 +138,7 @@ export function JobCardServiceForm({
job_card_service_id: jobCardServiceId, job_card_service_id: jobCardServiceId,
quantity: values.quantity, quantity: values.quantity,
rate: values.rate, rate: values.rate,
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
description: values.description || undefined, description: values.description || undefined,
}), }),
{ {
@ -146,13 +152,14 @@ export function JobCardServiceForm({
api.jobCards.addService(jobCardId, { api.jobCards.addService(jobCardId, {
service_id: values.service ? Number(values.service.value) : undefined, service_id: values.service ? Number(values.service.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined, department_id: values.department ? Number(values.department.value) : undefined,
tax_id: values.tax ? Number(values.tax.value) : undefined,
rate_type: values.rate_type || undefined, rate_type: values.rate_type || undefined,
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined, labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
quantity: values.quantity, quantity: values.quantity,
rate: values.rate, rate: values.rate,
working_hours: values.working_hours || undefined, working_hours: values.working_hours || undefined,
labor_hours: values.labor_hours || undefined, labor_hours: values.labor_hours || undefined,
tax: values.tax || undefined, discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
chart_of_account: values.chart_of_account || undefined, chart_of_account: values.chart_of_account || undefined,
description: values.description || undefined, description: values.description || undefined,
}), }),
@ -218,6 +225,15 @@ export function JobCardServiceForm({
/> />
</div> </div>
{isLineItemDiscount && (
<RhfTextField
name="discount_amount"
label="Discount Amount"
type="number"
placeholder="0.00"
/>
)}
{!isEditing && ( {!isEditing && (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@ -241,6 +257,7 @@ export function JobCardServiceForm({
/> />
</div> </div>
{isHourly && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="working_hours" name="working_hours"
@ -255,13 +272,15 @@ export function JobCardServiceForm({
placeholder="0" placeholder="0"
/> />
</div> </div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="department" name="department"
label="Department" label="Department"
placeholder="Select department" placeholder="Select department"
queryKey={["departments"]} required
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()} listFn={() => api.departments.list()}
mapOption={(item: any) => ({ mapOption={(item: any) => ({
value: String(item.id), value: String(item.id),
@ -271,10 +290,17 @@ export function JobCardServiceForm({
createLabel="Department" createLabel="Department"
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfTextField <RhfAsyncSelectField
name="tax" name="tax"
label="Tax" label="Tax"
placeholder="e.g. 5%" 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}
/> />
</div> </div>
@ -302,9 +328,21 @@ export function JobCardServiceForm({
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
{isPending {isPending
? isEditing ? "Updating..." : "Adding..." ? isEditing ? "Updating..." : "Adding..."
: isEditing ? "Update Service" : "Add Service"} : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Service
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Service
</>
)}
</Button> </Button>
</div> </div>
</Rhform> </Rhform>
) )
} }

View File

@ -59,6 +59,7 @@ const jobCardFormSchema = z.object({
sales_person: relationFieldSchema, sales_person: relationFieldSchema,
insurance_type: relationFieldSchema, insurance_type: relationFieldSchema,
insurer: relationFieldSchema, insurer: relationFieldSchema,
tax: relationFieldSchema,
// ── Numbers & identifiers ── // ── Numbers & identifiers ──
order_number: z.string().optional(), order_number: z.string().optional(),

View File

@ -22,6 +22,7 @@ type PartItem = {
title: string title: string
quantity: number quantity: number
rate: number rate: number
discount_amount?: number
description?: string description?: string
} }
@ -34,6 +35,7 @@ export type PartsSelectorFieldProps<
name: TName & (TValues[TName] extends PartsItemsFieldConstraint ? TName : never) name: TName & (TValues[TName] extends PartsItemsFieldConstraint ? TName : never)
label?: string label?: string
triggerLabel?: string triggerLabel?: string
showDiscount?: boolean
} }
export function PartsSelectorField< export function PartsSelectorField<
@ -43,6 +45,7 @@ export function PartsSelectorField<
name, name,
label = "Parts", label = "Parts",
triggerLabel = "Add Parts", triggerLabel = "Add Parts",
showDiscount = false,
}: PartsSelectorFieldProps<TValues, TName>) { }: PartsSelectorFieldProps<TValues, TName>) {
return ( return (
<RhfResourceField<TValues, TName, PartsClient> <RhfResourceField<TValues, TName, PartsClient>
@ -81,6 +84,7 @@ export function PartsSelectorField<
<TableHead>Part</TableHead> <TableHead>Part</TableHead>
<TableHead className="w-24">Qty</TableHead> <TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead> <TableHead className="w-28">Rate</TableHead>
{showDiscount && <TableHead className="w-28">Discount</TableHead>}
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="w-12" /> <TableHead className="w-12" />
</TableRow> </TableRow>
@ -112,6 +116,20 @@ export function PartsSelectorField<
className="h-8 w-24" className="h-8 w-24"
/> />
</TableCell> </TableCell>
{showDiscount && (
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.discount_amount ?? 0}
onChange={(e) =>
update(index, { ...item, discount_amount: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
)}
<TableCell> <TableCell>
<Input <Input
value={item.description ?? ""} value={item.description ?? ""}

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { useMemo } from "react"
import { AlertTriangle, Plus, Save } from "lucide-react" import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@ -11,12 +12,14 @@ import {
RhfSelectField, RhfSelectField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfAutoGenerateField,
RhfDateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation" import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils" import { toRelation, toId, getTodayDate } from "@/shared/lib/utils"
import { import {
paymentMadeFormSchema, paymentMadeFormSchema,
@ -25,10 +28,10 @@ import {
import { import {
PAYMENT_MADE_ROUTES, PAYMENT_MADE_ROUTES,
PAYMENT_MODE_ROUTES, PAYMENT_MODE_ROUTES,
VENDOR_ROUTES,
EMPLOYEE_ROUTES, EMPLOYEE_ROUTES,
PaymentFor, PaymentFor,
} from "@garage/api" } from "@garage/api"
import { RhfVendorSelectField } from "@/modules/vendors/rhf-vendor-select-field"
// ── Constants ── // ── Constants ──
@ -43,54 +46,70 @@ export type PaymentMadeFormProps = {
resourceId?: string | null resourceId?: string | null
initialData?: unknown initialData?: unknown
onSuccess?: () => void onSuccess?: () => void
billId?: string | number | null
expenseId?: string | number | null
} }
// ── Default values ── // ── Default values ──
const DEFAULT_VALUES: PaymentMadeFormValues = { const DEFAULT_VALUES: PaymentMadeFormValues & { details: Array<{ bill_id?: string | number | null; amount_paid?: number, expense_id?: string | number | null }> } = {
vendor: null, vendor: null,
employee: null, employee: null,
payment_mode: null, payment_mode: null,
payment_for: "", payment_for: "bill",
payment_made: "", amount: "",
payment_number: "", payment_number: "",
payment_reference: "", payment_reference: "",
payment_date: "", payment_date: getTodayDate(),
paid_through: "", paid_through: "",
notes: "", notes: "",
details: []
} }
// ── Mapping helpers ── // ── Mapping helpers ──
function mapToFormValues(data: unknown): PaymentMadeFormValues { function mapToFormValues(data: unknown): typeof DEFAULT_VALUES {
const d = (data as any)?.data ?? data ?? {} const d = (data as any)?.data ?? data ?? {}
// Resolve payment_mode label from nested object (title) or fallback to id
const paymentModeId = d.payment_mode_id ?? d.payment_mode?.id
const paymentModeLabel = d.payment_mode?.title ?? d.payment_mode?.name ?? d.payment_mode_name
return { return {
vendor: toRelation(d.vendor_id, d.vendor_name), vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_name),
employee: toRelation(d.employee_id, d.employee_name), employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : d.employee_name),
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name), payment_mode: toRelation(paymentModeId, paymentModeLabel),
payment_for: d.payment_for || "", payment_for: d.payment_for || "",
payment_made: d.payment_made ? String(d.payment_made) : "", amount: d.payment_made ? String(d.payment_made) : "",
payment_number: d.payment_number || "", payment_number: d.payment_number || "",
payment_reference: d.payment_reference || "", payment_reference: d.payment_reference || "",
payment_date: d.payment_date || "", payment_date: d.payment_date || "",
paid_through: d.paid_through || "", paid_through: d.paid_through || "-",
notes: d.notes || "", notes: d.notes || "-",
details: [{ bill_id: d.bill_id, amount_paid: d.amount_paid }],
} }
} }
function mapFormToPayload(values: PaymentMadeFormValues) {
function mapFormToPayload(values: PaymentMadeFormValues, billId?: string | number | null, expenseId?: string | number | null) {
const paymentDetails =
expenseId ? [{ expense_id: expenseId, amount_paid: values.amount ? Number(values.amount) : 0 }]
: [{ bill_id: billId, amount_paid: values.amount ? Number(values.amount) : 0 }]
return { return {
vendor_id: toId(values.vendor), vendor_id: toId(values.vendor),
employee_id: toId(values.employee) || undefined, employee_id: toId(values.employee) || undefined,
payment_mode_id: toId(values.payment_mode), payment_mode_id: toId(values.payment_mode),
payment_for: values.payment_for, payment_for: values.payment_for,
payment_made: values.payment_made, amount: values.amount ? Number(values.amount) : 0,
payment_number: values.payment_number || undefined, payment_number: values.payment_number || undefined,
payment_reference: values.payment_reference || undefined, payment_reference: values.payment_reference || undefined,
payment_date: values.payment_date, payment_date: values.payment_date,
paid_through: values.paid_through || undefined, paid_through: values.paid_through || undefined,
notes: values.notes || undefined, notes: values.notes || undefined,
payment_made: values.amount,
details: paymentDetails,
...(billId ? { bill_id: Number(billId) } : {}),
...(expenseId ? { expense_id: Number(expenseId) } : {}),
} }
} }
@ -117,20 +136,32 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// ── Component ── // ── Component ──
export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentMadeFormProps) { export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, expenseId }: PaymentMadeFormProps) {
const api = useAuthApi() const api = useAuthApi()
const resolvedInitialData = useMemo(() => {
const base: any = { ...(initialData as any) }
if (!resourceId) {
if (billId) {
base.payment_for = "bill"
} else if (expenseId) {
base.payment_for = "expense"
}
}
return Object.keys(base).length ? base : initialData
}, [resourceId, billId, expenseId, initialData])
const { form, isEditing } = useResourceForm<PaymentMadeFormValues, any>({ const { form, isEditing } = useResourceForm<PaymentMadeFormValues, any>({
schema: paymentMadeFormSchema, schema: paymentMadeFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
initialData, initialData: resolvedInitialData,
mapToFormValues, mapToFormValues,
}) })
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PaymentMadeFormValues) => { mutationFn: (values: PaymentMadeFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values, billId, expenseId)
const promise = (isEditing && resourceId const promise = (isEditing && resourceId
? api.paymentMades.update(resourceId, payload as any) ? api.paymentMades.update(resourceId, payload as any)
: api.paymentMades.create(payload as any)) as Promise<any> : api.paymentMades.create(payload as any)) as Promise<any>
@ -161,14 +192,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
<FieldGroup> <FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfVendorSelectField
name="vendor" name="vendor"
label="Vendor" label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapVendorOption}
{...STORE_OBJECT}
/> />
<RhfAsyncSelectField <RhfAsyncSelectField
name="employee" name="employee"
@ -190,7 +216,7 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
required required
/> />
<RhfTextField <RhfTextField
name="payment_made" name="amount"
label="Amount" label="Amount"
placeholder="0.00" placeholder="0.00"
type="number" type="number"
@ -199,7 +225,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfAutoGenerateField
table="payments"
autoFetch
name="payment_number" name="payment_number"
label="Payment Number" label="Payment Number"
placeholder="PAY-001" placeholder="PAY-001"
@ -212,10 +240,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfDateField
name="payment_date" name="payment_date"
label="Payment Date" label="Payment Date"
type="date"
required required
/> />
<RhfAsyncSelectField <RhfAsyncSelectField

View File

@ -11,8 +11,8 @@ const paymentMadeFormSchema = z.object({
payment_mode: relationFieldSchema, payment_mode: relationFieldSchema,
// ── Payment info ── // ── Payment info ──
amount: z.string().min(1, "Amount is required"),
payment_for: z.string().min(1, "Payment for is required"), payment_for: z.string().min(1, "Payment for is required"),
payment_made: z.string().min(1, "Amount is required"),
payment_number: z.string().optional(), payment_number: z.string().optional(),
payment_reference: z.string().optional(), payment_reference: z.string().optional(),
payment_date: z.string().min(1, "Payment date is required"), payment_date: z.string().min(1, "Payment date is required"),

View File

@ -11,6 +11,8 @@ import {
RhfTextField, RhfTextField,
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField,
RhfAutoGenerateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
@ -31,6 +33,9 @@ export type PaymentReceivedFormProps = {
initialData?: unknown initialData?: unknown
onSuccess?: () => void onSuccess?: () => void
defaultJobCard?: { id?: number | null; title?: string | null } | null defaultJobCard?: { id?: number | null; title?: string | null } | null
invoiceId?: string | null
invoiceCustomer?: { id?: number | null; first_name?: string | null; last_name?: string | null } | null
invoiceAmount?: number | string | null
} }
// ── Default values ── // ── Default values ──
@ -41,7 +46,7 @@ const DEFAULT_VALUES: PaymentReceivedFormValues = {
customer: null, customer: null,
amount_received: "", amount_received: "",
payment_number: "", payment_number: "",
payment_date: "", payment_date: new Date().toISOString().split("T")[0],
note: "", note: "",
} }
@ -61,7 +66,7 @@ function mapToFormValues(data: unknown): PaymentReceivedFormValues {
} }
} }
function mapFormToPayload(values: PaymentReceivedFormValues) { function mapFormToPayload(values: PaymentReceivedFormValues, invoiceId?: string | null) {
return { return {
job_card_id: toId(values.job_card), job_card_id: toId(values.job_card),
payment_mode_id: toId(values.payment_mode), payment_mode_id: toId(values.payment_mode),
@ -70,6 +75,7 @@ function mapFormToPayload(values: PaymentReceivedFormValues) {
payment_number: values.payment_number || undefined, payment_number: values.payment_number || undefined,
payment_date: values.payment_date, payment_date: values.payment_date,
note: values.note || undefined, note: values.note || undefined,
...(invoiceId ? { invoice_id: Number(invoiceId) } : {}),
} }
} }
@ -84,18 +90,29 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
// ── Component ── // ── Component ──
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard }: PaymentReceivedFormProps) { export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard, invoiceId, invoiceCustomer, invoiceAmount }: PaymentReceivedFormProps) {
const api = useAuthApi() const api = useAuthApi()
const resolvedInitialData = useMemo(() => { const resolvedInitialData = useMemo(() => {
if (!resourceId && defaultJobCard?.id != null) { const base: any = { ...(initialData as any) }
return { if (!resourceId) {
...(initialData as any), if (defaultJobCard?.id != null) {
job_card: toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined), base.job_card = toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined)
}
if (invoiceCustomer?.id != null) {
base.customer = toRelation(
invoiceCustomer.id,
invoiceCustomer.first_name
? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim()
: undefined
)
}
if (invoiceAmount != null && invoiceAmount !== "") {
base.amount_received = String(invoiceAmount)
} }
} }
return initialData return Object.keys(base).length ? base : initialData
}, [resourceId, defaultJobCard, initialData]) }, [resourceId, defaultJobCard, initialData, invoiceCustomer, invoiceAmount])
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({ const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
schema: paymentReceivedFormSchema, schema: paymentReceivedFormSchema,
@ -107,7 +124,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PaymentReceivedFormValues) => { mutationFn: (values: PaymentReceivedFormValues) => {
const payload = mapFormToPayload(values) const payload = mapFormToPayload(values, invoiceId)
const promise = isEditing && resourceId const promise = isEditing && resourceId
? api.paymentReceived.update(resourceId, payload as any) ? api.paymentReceived.update(resourceId, payload as any)
: api.paymentReceived.create(payload as any) : api.paymentReceived.create(payload as any)
@ -186,16 +203,16 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfAutoGenerateField
autoFetch
table="payments"
name="payment_number" name="payment_number"
label="Payment Number" label="Payment Number"
placeholder="PAY-001" placeholder="PAY-001"
/> />
<RhfTextField <RhfDateField
name="payment_date" name="payment_date"
label="Payment Date" label="Payment Date"
type="date"
required
/> />
</div> </div>

View File

@ -13,6 +13,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { BillForm } from "@/modules/bills/bill-form" import { BillForm } from "@/modules/bills/bill-form"
import { toRelation } from "@/shared/lib/utils" import { toRelation } from "@/shared/lib/utils"
import { usePurchaseOrder } from "./purchase-order-context" import { usePurchaseOrder } from "./purchase-order-context"
import { getFullName } from "@/shared/utils/getFullName"
/** /**
* Maps a Purchase Order data object to a complete BillFormValues shape so that * Maps a Purchase Order data object to a complete BillFormValues shape so that
@ -26,9 +27,9 @@ function mapPOToBillInitialData(po: Record<string, any>) {
notes: po.notes ?? "", notes: po.notes ?? "",
// Relation fields — must be { value, label } objects for RhfAsyncSelectField // Relation fields — must be { value, label } objects for RhfAsyncSelectField
vendor: toRelation(po.vendor_id, po.vendor_name), vendor: toRelation(po.vendor_id, getFullName(po.vendor)),
department: toRelation(po.department_id, po.department_name), department: toRelation(po.department_id, po.department.name),
job_card: toRelation(po.job_card_id, po.job_card_name ?? po.job_card_number), job_card: toRelation(po.job_card_id, po.job_card.title ),
// Link bill back to the source PO // Link bill back to the source PO
purchase_order: toRelation(po.id, po.order_number ?? po.title), purchase_order: toRelation(po.id, po.order_number ?? po.title),

View File

@ -11,8 +11,10 @@ import {
RhfTextareaField, RhfTextareaField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfDateField, RhfDateField,
RhfAutoGenerateField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { PartsSelectorField } from "@/modules/parts/parts-selector-field" import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { VendorForm } from "@/modules/vendors/vendor-form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
@ -98,6 +100,13 @@ const mapLookupOption = (item: any) => ({
label: item.name, label: item.name,
}) })
const getVendorLabel = (item: any) => getFullName(item) || item.company_name || item.name || `#${item.id}`
const mapVendorOption = (item: any) => ({
value: String(item.id),
label: getVendorLabel(item),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ── // ── Component ──
@ -148,7 +157,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required /> <RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<RhfTextField name="order_number" label="Order Number" placeholder="Enter purchase order number" required /> <RhfAutoGenerateField autoFetch table="purchase-order" name="order_number" label="Order Number" placeholder="Enter purchase order number" required />
<RhfDateField name="order_date" label="Order Date" /> <RhfDateField name="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" /> <RhfDateField name="delivery_date" label="Delivery Date" />
</div> </div>
@ -160,7 +169,16 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
placeholder="Select vendor" placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]} queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()} listFn={() => api.vendors.list()}
mapOption={(op: any) => ({ label: getFullName(op as any), value: String(op.id) })} mapOption={mapVendorOption}
createForm={({ onSuccess }) => (
<VendorForm
onSuccess={(data) => {
const vendor = (data as any)?.data ?? data
onSuccess(vendor?.id ? mapVendorOption(vendor) : undefined)
}}
/>
)}
createLabel="Vendor"
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfAsyncSelectField <RhfAsyncSelectField

View File

@ -22,6 +22,7 @@ type ServiceLineItem = {
title: string title: string
quantity: number quantity: number
rate: number rate: number
discount_amount?: number
description?: string description?: string
} }
@ -34,6 +35,7 @@ export type ServicesSelectorFieldProps<
name: TName & (TValues[TName] extends ServiceItemsFieldConstraint ? TName : never) name: TName & (TValues[TName] extends ServiceItemsFieldConstraint ? TName : never)
label?: string label?: string
triggerLabel?: string triggerLabel?: string
showDiscount?: boolean
} }
export function ServicesSelectorField< export function ServicesSelectorField<
@ -43,6 +45,7 @@ export function ServicesSelectorField<
name, name,
label = "Services", label = "Services",
triggerLabel = "Add Services", triggerLabel = "Add Services",
showDiscount = false,
}: ServicesSelectorFieldProps<TValues, TName>) { }: ServicesSelectorFieldProps<TValues, TName>) {
return ( return (
<RhfResourceField<TValues, TName, ServicesClient> <RhfResourceField<TValues, TName, ServicesClient>
@ -79,6 +82,7 @@ export function ServicesSelectorField<
<TableHead>Service</TableHead> <TableHead>Service</TableHead>
<TableHead className="w-24">Qty</TableHead> <TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead> <TableHead className="w-28">Rate</TableHead>
{showDiscount && <TableHead className="w-28">Discount</TableHead>}
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead className="w-12" /> <TableHead className="w-12" />
</TableRow> </TableRow>
@ -110,6 +114,20 @@ export function ServicesSelectorField<
className="h-8 w-24" className="h-8 w-24"
/> />
</TableCell> </TableCell>
{showDiscount && (
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.discount_amount ?? 0}
onChange={(e) =>
update(index, { ...item, discount_amount: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
)}
<TableCell> <TableCell>
<Input <Input
value={item.description ?? ""} value={item.description ?? ""}

View File

@ -148,9 +148,14 @@ export function SettingsForm() {
</Alert> </Alert>
)} )}
<FieldGroup> <div className="grid gap-6 lg:grid-cols-12">
{/* General Info */} {/* Main Content - 8/12 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="lg:col-span-8">
<FieldGroup className="space-y-6">
{/* General Information Section */}
<div>
<h3 className="mb-4 text-base font-semibold">General Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="name" name="name"
label="Workshop Name" label="Workshop Name"
@ -167,7 +172,7 @@ export function SettingsForm() {
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="phone" name="phone"
label="Phone" label="Phone"
@ -184,7 +189,7 @@ export function SettingsForm() {
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="website" name="website"
label="Website" label="Website"
@ -198,38 +203,28 @@ export function SettingsForm() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="time_zone"
label="Time Zone"
placeholder="Asia/Dubai"
disabled={isLoading}
/>
<RhfSelectField
name="first_day_of_work"
label="First Day of Work"
placeholder="Select day"
options={FIRST_DAY_OPTIONS}
disabled={isLoading}
/>
</div> </div>
{/* Address */} {/* Address Section */}
<div className="border-t pt-6">
<h3 className="mb-4 text-base font-semibold">Address</h3>
<RhfTextField <RhfTextField
name="first_address_line" name="first_address_line"
label="Address Line 1" label="Address Line 1"
placeholder="Street 10" placeholder="Street 10"
disabled={isLoading} disabled={isLoading}
/> />
<div className="mt-4">
<RhfTextField <RhfTextField
name="second_address_line" name="second_address_line"
label="Address Line 2" label="Address Line 2"
placeholder="Near Central Plaza" placeholder="Near Central Plaza"
disabled={isLoading} disabled={isLoading}
/> />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<RhfAsyncSelectField <RhfAsyncSelectField
name="country" name="country"
label="Country" label="Country"
@ -250,7 +245,7 @@ export function SettingsForm() {
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="city" name="city"
label="City" label="City"
@ -264,15 +259,45 @@ export function SettingsForm() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
</div>
{/* More Information Section */}
<div className="border-t pt-6">
<h3 className="mb-4 text-base font-semibold">More Information</h3>
<RhfTextareaField
name="description"
label="Description"
placeholder="About the workshop..."
disabled={isLoading}
/>
<div className="mt-4">
<RhfTextareaField
name="bank_details"
label="Bank Details"
placeholder="Bank name, account number, IBAN..."
disabled={isLoading}
/>
</div>
</div>
</FieldGroup>
</div>
{/* Sidebar - 4/12 */}
<div className="lg:col-span-4">
<FieldGroup className="sticky top-24 space-y-6">
{/* Location & Time Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 text-base font-semibold">Location & Time</h3>
{/* Location */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField <RhfTextField
name="latitude" name="latitude"
label="Latitude" label="Latitude"
placeholder="25.2048" placeholder="25.2048"
disabled={isLoading} disabled={isLoading}
/> />
<div className="mt-4">
<RhfTextField <RhfTextField
name="longitude" name="longitude"
label="Longitude" label="Longitude"
@ -281,39 +306,62 @@ export function SettingsForm() {
/> />
</div> </div>
{/* Other */} <div className="mt-4">
<RhfTextareaField <RhfTextField
name="bank_details" name="time_zone"
label="Bank Details" label="Time Zone"
placeholder="Bank name, account number, IBAN..." placeholder="Asia/Dubai"
disabled={isLoading} disabled={isLoading}
/> />
<RhfTextareaField </div>
name="description"
label="Description" <div className="mt-4">
placeholder="About the workshop..." <RhfSelectField
name="first_day_of_work"
label="First Day of Work"
placeholder="Select day"
options={FIRST_DAY_OPTIONS}
disabled={isLoading} disabled={isLoading}
/> />
</div>
</div>
{/* Policies Section */}
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 text-base font-semibold">Policies</h3>
<RhfTextareaField <RhfTextareaField
name="security" name="security"
label="Security Policy" label="Security Policy"
placeholder="Security policy text..." placeholder="Security policy text..."
disabled={isLoading} disabled={isLoading}
/> />
<div className="mt-4">
<RhfTextareaField <RhfTextareaField
name="privacy_policy" name="privacy_policy"
label="Privacy Policy" label="Privacy Policy"
placeholder="Privacy policy text..." placeholder="Privacy policy text..."
disabled={isLoading} disabled={isLoading}
/> />
</div>
</div>
<div className="flex justify-end"> {/* Action Button */}
<Button type="submit" disabled={isPending || isLoading}> <div className="flex flex-col gap-2">
<Save /> <Button
type="submit"
disabled={isPending || isLoading}
size="lg"
className="w-full"
>
<Save className="me-2" />
{isPending ? "Saving..." : "Save Settings"} {isPending ? "Saving..." : "Save Settings"}
</Button> </Button>
</div> </div>
</FieldGroup> </FieldGroup>
</div>
</div>
</Rhform> </Rhform>
) )
} }

View File

@ -0,0 +1,237 @@
"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Building2, Loader2, PlusIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { VENDOR_ROUTES } from "@garage/api"
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { VendorForm } from "./vendor-form"
// ── Vendor option type ──
type VendorOption = {
value: string
label: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
}
function buildVendorOption(item: any): VendorOption {
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
const label = item.company_name || name || `Vendor #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
company_name: item.company_name,
email: item.email,
phone: item.phone,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: VendorOption): string {
if (opt.company_name) return opt.company_name[0].toUpperCase()
if (opt.first_name || opt.last_name) {
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
}
return "V"
}
// ── Props ──
export type RhfVendorSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfVendorSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Vendor",
description,
required,
disabled,
placeholder = "Search by company name, name, or phone...",
}: RhfVendorSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const [isCreateOpen, setIsCreateOpen] = useState(false)
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<VendorOption[]>({
queryKey: [VENDOR_ROUTES.INDEX, "vendor-select"],
queryFn: async () => {
const res = await api.vendors.list()
const items = extractItems(res)
return items.map(buildVendorOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((v) =>
[v.company_name, v.first_name, v.last_name, v.email, v.phone]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
const handleCreateSuccess = (data?: any) => {
const item = data?.data ?? data
if (item?.id) {
field.onChange(buildVendorOption(item))
}
queryClient.invalidateQueries({ queryKey: [VENDOR_ROUTES.INDEX, "vendor-select"] })
setIsCreateOpen(false)
}
return (
<Field data-invalid={!!error?.message || undefined}>
{label && (
<div className="flex items-center justify-between">
<FieldLabel>
{label}
{required && <span className="text-destructive ms-0.5">*</span>}
</FieldLabel>
<Button
type="button"
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => setIsCreateOpen(true)}
title={`Add new ${label}`}
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: VendorOption | VendorOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
field.onChange(
single ? { value: single.value, label: single.label } : null,
)
}}
disabled={field.disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: VendorOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList className="overflow-auto">
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-2 min-w-0">
{opt.company_name && opt.label !== opt.company_name && (
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
{opt.company_name}
</span>
)}
{opt.phone && (
<span className="text-xs text-muted-foreground truncate">
{opt.phone}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No vendors found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{description && <FieldDescription>{description}</FieldDescription>}
{error?.message && <FieldError>{error.message}</FieldError>}
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Add Vendor</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<VendorForm onSuccess={handleCreateSuccess} />
</ScrollArea>
</DialogContent>
</Dialog>
</Field>
)
}

View File

@ -25,17 +25,18 @@ import { VENDOR_ROUTES } from "@garage/api"
export type VendorFormProps = { export type VendorFormProps = {
resourceId?: string | null resourceId?: string | null
initialData?: unknown initialData?: unknown
onSuccess?: () => void onSuccess?: (data?: unknown) => void
} }
// ── Default values ── // ── Default values ──
const DEFAULT_VALUES: VendorFormValues = { const DEFAULT_VALUES: VendorFormValues = {
salutation:"Mr.",
first_name: "", first_name: "",
last_name: "", last_name: "",
company_name: "", company_name: "",
email: "", email: "",
} } as any
// ── Mapping helpers ── // ── Mapping helpers ──
@ -85,9 +86,9 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
}) })
return promise return promise
}, },
onSuccess: () => { onSuccess: (data) => {
form.reset() form.reset()
onSuccess?.() onSuccess?.(data)
}, },
}) })

View File

@ -0,0 +1,110 @@
"use client"
import { Separator } from "@/shared/components/ui/separator"
import { formatCurrency } from "@/shared/utils/formatters"
import { cn } from "@/shared/lib/utils"
import type { DocumentTotals } from "@/shared/hooks/use-document-totals"
export type DocumentTotalsSummaryProps = {
totals: DocumentTotals
discountType?: string | null
taxLabel?: string | null
/** Override labels for line groups, keyed by group name */
groupLabels?: Record<string, number>
}
function SummaryRow({
label,
value,
muted = false,
className,
}: {
label: string
value: string
muted?: boolean
className?: string
}) {
return (
<div className={cn("flex items-center justify-between text-sm", className)}>
<span className={muted ? "text-muted-foreground" : ""}>{label}</span>
<span className={muted ? "text-muted-foreground" : ""}>{value}</span>
</div>
)
}
export function DocumentTotalsSummary({
totals,
discountType,
taxLabel,
groupLabels,
}: DocumentTotalsSummaryProps) {
const {
subTotal,
lineItemDiscount,
transactionDiscount,
totalDiscount,
taxAmount,
total,
hasLineItems,
} = totals
if (!hasLineItems) return null
return (
<div className="space-y-2">
{/* Group breakdowns */}
{groupLabels &&
Object.entries(groupLabels).map(([label, amount]) => (
<SummaryRow
key={label}
label={label}
value={formatCurrency(amount)}
muted
/>
))}
{groupLabels && Object.keys(groupLabels).length > 0 && <Separator />}
{/* Subtotal */}
<SummaryRow label="Subtotal" value={formatCurrency(subTotal)} />
{/* Line-item discount */}
{discountType === "line_item_level" && lineItemDiscount > 0 && (
<SummaryRow
label="Line Discounts"
value={` ${formatCurrency(lineItemDiscount)}`}
muted
/>
)}
{/* Transaction-level discount */}
{discountType === "transaction_level" && transactionDiscount > 0 && (
<SummaryRow
label="Discount"
value={` ${formatCurrency(transactionDiscount)}`}
muted
/>
)}
{/* Tax */}
{taxAmount > 0 && (
<SummaryRow
label={taxLabel ?? "Tax"}
value={`+ ${formatCurrency(taxAmount)}`}
muted
/>
)}
<Separator />
{/* Total */}
<div className={cn(
"flex items-center justify-between rounded-md px-3 py-2 text-sm font-semibold",
"bg-primary/10 text-primary",
)}>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
)
}

View File

@ -109,15 +109,14 @@ export function RhfResourceField<
{triggerLabel ?? label} {triggerLabel ?? label}
</Button> </Button>
</CardHeader> </CardHeader>
{
items.length > 0 &&
<CardContent> <CardContent>
{(items as any[]).length > 0 {(items as any[]).length > 0
? renderItems(items, helpers) && renderItems(items, helpers)
: ( }
<p className="text-sm text-muted-foreground text-center py-4">
No items added yet.
</p>
)}
</CardContent> </CardContent>
}
</Card> </Card>
{error && <FieldError>{error.message}</FieldError>} {error && <FieldError>{error.message}</FieldError>}
<ResourceSelectorDialog<TClient> <ResourceSelectorDialog<TClient>

View File

@ -44,7 +44,11 @@ function ActionsCell<TData extends { id: string | number }>({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{options.onEdit && ( {options.onEdit && (
<DropdownMenuItem onClick={() => options.onEdit!(row.original)}> <DropdownMenuItem onClick={(e) => {
e.stopPropagation()
options.onEdit!(row.original)
}}>
<Pencil className="size-3.5 text-muted-foreground" /> <Pencil className="size-3.5 text-muted-foreground" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
@ -52,7 +56,10 @@ function ActionsCell<TData extends { id: string | number }>({
{options.onDelete && ( {options.onDelete && (
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => options.onDelete!(row.original)} onClick={(e) => {
e.stopPropagation()
options.onDelete!(row.original)
}}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
Delete Delete

View File

@ -0,0 +1,84 @@
/**
* useDocumentTotals
*
* Pure calculation hook for invoice-style documents (invoices, bills, estimates,
* credit notes, vendor credits, etc.).
*
* Inputs
*
* lineItems flat array of all items (parts + services + expenses)
* discountType 'no' | 'line_item_level' | 'transaction_level'
* discountAmount used when discountType === 'transaction_level'
* taxRate percentage (e.g. 15 for 15 %). Undefined no tax.
*
* Calculation order
*
* 1. subTotal = Σ (qty × rate)
* 2. lineItemDiscount = Σ discount_amount (only when discountType === 'line_item_level')
* 3. transactionDiscount = discountAmount (only when discountType === 'transaction_level')
* 4. totalDiscount = lineItemDiscount + transactionDiscount
* 5. afterDiscount = subTotal - totalDiscount
* 6. taxAmount = afterDiscount × (taxRate / 100)
* 7. total = afterDiscount + taxAmount
*/
export type DocumentLineItem = {
quantity: number
rate: number
discount_amount?: number
}
export type UseDocumentTotalsInput = {
lineItems: DocumentLineItem[]
discountType?: string | null
discountAmount?: number
taxRate?: number
}
export type DocumentTotals = {
subTotal: number
lineItemDiscount: number
transactionDiscount: number
totalDiscount: number
afterDiscount: number
taxAmount: number
total: number
hasLineItems: boolean
}
export function useDocumentTotals({
lineItems,
discountType,
discountAmount,
taxRate,
}: UseDocumentTotalsInput): DocumentTotals {
const subTotal = lineItems.reduce((sum, item) => {
const qty = Number(item.quantity) || 0
const rate = Number(item.rate) || 0
return sum + qty * rate
}, 0)
const lineItemDiscount =
discountType === "line_item_level"
? lineItems.reduce((sum, item) => sum + (Number(item.discount_amount) || 0), 0)
: 0
const transactionDiscount =
discountType === "transaction_level" ? Number(discountAmount) || 0 : 0
const totalDiscount = lineItemDiscount + transactionDiscount
const afterDiscount = Math.max(0, subTotal - totalDiscount)
const taxAmount = taxRate ? afterDiscount * (taxRate / 100) : 0
const total = afterDiscount + taxAmount
return {
subTotal,
lineItemDiscount,
transactionDiscount,
totalDiscount,
afterDiscount,
taxAmount,
total,
hasLineItems: lineItems.length > 0,
}
}

View File

@ -24,3 +24,8 @@ export function toId(relation: RelationFieldValue | undefined): number | undefin
return relation ? Number(relation.value) : undefined return relation ? Number(relation.value) : undefined
} }
export function getTodayDate() {
const today = new Date().toISOString().split("T")[0]
return today
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { CrudClient } from "../infra/crud-client" import { CrudClient } from "../infra/crud-client"
import type { ApiClientOptions } from "../infra/client" import type { ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiResponse } from "../infra/types" import type { ApiPath, ApiRequestBody, ApiResponse } from "../infra/types"
export const ESTIMATE_ROUTES = { export const ESTIMATE_ROUTES = {
INDEX: "/api/estimates", INDEX: "/api/estimates",
@ -12,6 +12,7 @@ export const ESTIMATE_ROUTES = {
EXPENSE_ITEMS: "/api/estimate/{id}/expense-items", EXPENSE_ITEMS: "/api/estimate/{id}/expense-items",
EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}", EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}",
STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation", STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation",
CONVERT_TO_JOB_CARD: "/api/estimates/{id}/convert-to-job-card",
} as const satisfies Record<string, ApiPath> } as const satisfies Record<string, ApiPath>
export class EstimatesClient extends CrudClient< export class EstimatesClient extends CrudClient<
@ -22,13 +23,6 @@ export class EstimatesClient extends CrudClient<
super(baseUrl, defaultOptions, ESTIMATE_ROUTES.INDEX, ESTIMATE_ROUTES.BY_ID) super(baseUrl, defaultOptions, ESTIMATE_ROUTES.INDEX, ESTIMATE_ROUTES.BY_ID)
} }
// Note: GET /api/estimates/{id} is not in the OpenAPI schema.
// This method uses a type cast and relies on the backend supporting the route.
async getById(id: string) {
const data = await this.get(ESTIMATE_ROUTES.BY_ID, { params: { id } })
return data;
}
// ── Estimate Services ── // ── Estimate Services ──
async listServices(estimateId: string) { async listServices(estimateId: string) {
return this.get(ESTIMATE_ROUTES.SERVICES, { params: { id: estimateId } } as never) return this.get(ESTIMATE_ROUTES.SERVICES, { params: { id: estimateId } } as never)
@ -91,4 +85,8 @@ export class EstimatesClient extends CrudClient<
}) { }) {
return this.post(ESTIMATE_ROUTES.STORE_AUTHORISATION, payload as never, { params: { id: estimateId } } as never) return this.post(ESTIMATE_ROUTES.STORE_AUTHORISATION, payload as never, { params: { id: estimateId } } as never)
} }
async convertToJobCard(estimateId: string, payload: ApiRequestBody<typeof ESTIMATE_ROUTES.CONVERT_TO_JOB_CARD, "post">) {
return this.post(ESTIMATE_ROUTES.CONVERT_TO_JOB_CARD, payload, { params: { id: estimateId } })
}
} }

View File

@ -66,6 +66,10 @@ export class ExpensesClient extends CrudClient<
return this.list(query) return this.list(query)
} }
async getById(id: string) {
const res = await this.show(id)
return res;
}
async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) { async createExpense(payload: ApiRequestBody<typeof EXPENSE_ROUTES.EXPENSES, "post">) {
return this.create(payload) return this.create(payload)
} }

View File

@ -18,7 +18,7 @@ export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
export { QuickRemarksClient, QUICK_REMARK_ROUTES } from "./quick-remarks" export { QuickRemarksClient, QUICK_REMARK_ROUTES } from "./quick-remarks"
export { QuickNotesClient, QUICK_NOTE_ROUTES } from "./quick-notes" export { QuickNotesClient, QUICK_NOTE_ROUTES } from "./quick-notes"
export { ShopRecommendationsClient, SHOP_RECOMMENDATION_ROUTES } from "./shop-recommendations" export { ShopRecommendationsClient, SHOP_RECOMMENDATION_ROUTES } from "./shop-recommendations"
export { JobCardsClient, JOB_CARD_ROUTES } from "./job-cards" export { JobCardsClient, JOB_CARD_ROUTES, type JobCardShowData } from "./job-cards"
export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes" export { PaymentModesClient, PAYMENT_MODE_ROUTES } from "./payment-modes"
export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received" export { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"
export { PartsClient, PARTS_ROUTES } from "./parts" export { PartsClient, PARTS_ROUTES } from "./parts"
@ -34,7 +34,7 @@ export { ShopTimingsClient, SHOP_TIMING_ROUTES } from "./shop-timings"
export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars" export { ShopCalendarsClient, SHOP_CALENDAR_ROUTES } from "./shop-calendars"
export { HolidayYearsClient, HOLIDAY_YEAR_ROUTES } from "./holiday-years" export { HolidayYearsClient, HOLIDAY_YEAR_ROUTES } from "./holiday-years"
export { TaxesClient, TAX_ROUTES } from "./taxes" export { TaxesClient, TAX_ROUTES } from "./taxes"
export { InvoicesClient, INVOICE_ROUTES } from "./invoices" export { InvoicesClient, INVOICE_ROUTES, type InvoiceShowData } from "./invoices"
export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home" export { HomeClient, HOME_ROUTES, type HomeDashboardResponse } from "./home"
export { BillsClient, BILL_ROUTES } from "./bills" export { BillsClient, BILL_ROUTES } from "./bills"
export { ReasonsClient, REASON_ROUTES } from "./reasons" export { ReasonsClient, REASON_ROUTES } from "./reasons"

View File

@ -1,7 +1,7 @@
import { CrudClient } from "../infra/crud-client" import { CrudClient } from "../infra/crud-client"
import { type ApiClientOptions } from "../infra/client" import { type ApiClientOptions } from "../infra/client"
import type { ApiPath, ApiRequestBody } from "../infra/types" import type { ApiPath, ApiRequestBody } from "../infra/types"
import type { ApiListQueryParams } from "../contracts/types" import type { ApiListQueryParams, ApiBaseResponse } from "../contracts/types"
export const INVOICE_ROUTES = { export const INVOICE_ROUTES = {
INDEX: "/api/invoices", INDEX: "/api/invoices",
@ -16,9 +16,28 @@ export const INVOICE_ROUTES = {
LABEL_BY_ID: "/api/invoice-labels/{id}", LABEL_BY_ID: "/api/invoice-labels/{id}",
} as const satisfies Record<string, ApiPath> } as const satisfies Record<string, ApiPath>
// Enhanced invoice type with additional fields
export interface InvoiceShowData {
id?: string | number
amount?: string | number
received_payment?: string | number
customer?: {
id?: string | number
first_name?: string
last_name?: string
company_name?: string
}
customer_id?: string | number
customer_name?: string
[key: string]: any // Allow additional properties
}
export class InvoicesClient extends CrudClient< export class InvoicesClient extends CrudClient<
typeof INVOICE_ROUTES.INDEX, typeof INVOICE_ROUTES.INDEX,
typeof INVOICE_ROUTES.BY_ID typeof INVOICE_ROUTES.BY_ID,
{
showResponse: ApiBaseResponse<InvoiceShowData>
}
> { > {
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) { constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
super(baseUrl, defaultOptions, INVOICE_ROUTES.INDEX, INVOICE_ROUTES.BY_ID) super(baseUrl, defaultOptions, INVOICE_ROUTES.INDEX, INVOICE_ROUTES.BY_ID)

View File

@ -30,6 +30,11 @@ export const JOB_CARD_ROUTES = {
DELETE_SERVICE: "/api/job-cards/{id}/delete-service", DELETE_SERVICE: "/api/job-cards/{id}/delete-service",
ADD_SERVICE_ATTACHMENT: "/api/job-cards/{id}/add-service-attachment", ADD_SERVICE_ATTACHMENT: "/api/job-cards/{id}/add-service-attachment",
DELETE_SERVICE_ATTACHMENT: "/api/job-cards/{id}/delete-service-attachment", DELETE_SERVICE_ATTACHMENT: "/api/job-cards/{id}/delete-service-attachment",
GET_EXPENSE_ITEMS: "/api/job-cards/{id}/get-expense-items",
ADD_EXPENSE_ITEM: "/api/job-cards/{id}/add-expense-item",
UPDATE_EXPENSE_ITEM: "/api/job-cards/{id}/update-expense-item",
DELETE_EXPENSE_ITEM: "/api/job-cards/{id}/delete-expense-item",
CONVERT_TO_INVOICE: "/api/job-cards/{id}/convert-to-invoice",
} as const satisfies Record<string, ApiPath> } as const satisfies Record<string, ApiPath>
@ -157,4 +162,24 @@ export class JobCardsClient extends CrudClient<
async deleteServiceAttachment(id: string, jobCardServiceId: number, attachmentId: number) { async deleteServiceAttachment(id: string, jobCardServiceId: number, attachmentId: number) {
return this.delete(JOB_CARD_ROUTES.DELETE_SERVICE_ATTACHMENT, { params: { id }, body: { job_card_service_id: jobCardServiceId, attachment_id: attachmentId } } as never) return this.delete(JOB_CARD_ROUTES.DELETE_SERVICE_ATTACHMENT, { params: { id }, body: { job_card_service_id: jobCardServiceId, attachment_id: attachmentId } } as never)
} }
async getExpenseItems(id: string, params?: Record<string, unknown>) {
return this.get(JOB_CARD_ROUTES.GET_EXPENSE_ITEMS, { params: { id }, query: params as any })
}
async addExpenseItem(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.ADD_EXPENSE_ITEM, "post">) {
return this.post(JOB_CARD_ROUTES.ADD_EXPENSE_ITEM, payload, { params: { id } })
}
async updateExpenseItem(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.UPDATE_EXPENSE_ITEM, "put">) {
return this.put(JOB_CARD_ROUTES.UPDATE_EXPENSE_ITEM, payload, { params: { id } })
}
async deleteExpenseItem(id: string, jobCardExpenseItemId: number) {
return this.delete(JOB_CARD_ROUTES.DELETE_EXPENSE_ITEM, { params: { id }, body: { job_card_expense_item_id: jobCardExpenseItemId } } as never)
}
async convertToInvoice(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CONVERT_TO_INVOICE, "post">) {
return this.post(JOB_CARD_ROUTES.CONVERT_TO_INVOICE, payload, { params: { id } })
}
} }

View File

@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number]; export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
// Tables // Tables
export const Tables= ['bills', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits', 'estimates'] as const; export const Tables= ['bills', 'payments', 'expenses', 'invoices', 'job_cards', 'credit_notes', 'vendor_credits', 'estimates','purchase-order'] as const;
export type Tables = (typeof Tables)[number]; export type Tables = (typeof Tables)[number];
export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const; export const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;

File diff suppressed because it is too large Load Diff