updates
This commit is contained in:
parent
f17dd1486c
commit
c0f78c6e18
95
.github/skills/invoice-pattern/SKILL.md
vendored
Normal file
95
.github/skills/invoice-pattern/SKILL.md
vendored
Normal 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>
|
||||
```
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
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 { BILL_ROUTES } from "@garage/api"
|
||||
import type { BillsClient } from "@garage/api"
|
||||
import { formatDate } from "@/shared/utils/formatters"
|
||||
|
||||
export default function BillsPage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<ResourcePage<BillsClient>
|
||||
pageTitle="Bills"
|
||||
routeKey={BILL_ROUTES.INDEX}
|
||||
getClient={(api) => api.bills}
|
||||
onRowClick={(row) => router.push(`/purchase/bill/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Bill">
|
||||
<FormDialog classNames={{ dialogContent: "lg:min-w-6xl" }} title="Bill">
|
||||
{(resourceId) => (
|
||||
<BillForm
|
||||
resourceId={resourceId}
|
||||
@ -33,37 +38,45 @@ export default function BillsPage() {
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill #" />,
|
||||
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",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
||||
},
|
||||
{
|
||||
accessorKey: "vendor_name",
|
||||
accessorKey: "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",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Bill Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
cell: ({ row }) => formatDate((row.original as any).bill_date) || "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "bill_due_date",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Due Date" />,
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as any).bill_due_date
|
||||
return value ? new Date(value).toLocaleDateString() : "—"
|
||||
},
|
||||
cell: ({ row }) => formatDate((row.original as any).bill_due_date) || "—",
|
||||
},
|
||||
{
|
||||
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>
|
||||
return (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status?.replace(/_/g, " ") || "—"}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
actionsColumn(),
|
||||
@ -71,3 +84,4 @@ export default function BillsPage() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -7,16 +7,23 @@ 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 { useRouter } from "next/navigation"
|
||||
import { formatCurrency, formatDate, formatEnum } from "@/shared/utils/formatters"
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<ResourcePage<ExpensesClient>
|
||||
pageTitle="Expenses"
|
||||
routeKey={EXPENSE_ROUTES.INDEX}
|
||||
getClient={(api) => api.expenses}
|
||||
onRowClick={(row)=>router.push(`/purchase/expense/${row.id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Expense">
|
||||
<FormDialog
|
||||
title="Expense"
|
||||
classNames={{ dialogContent: "lg:min-w-6xl" }}
|
||||
>
|
||||
{(resourceId) => (
|
||||
<ExpenseForm
|
||||
resourceId={resourceId}
|
||||
@ -38,38 +45,47 @@ export default function ExpensesPage() {
|
||||
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: "vendor",
|
||||
header: () => "Vendor",
|
||||
cell: ({ row }) => {
|
||||
const vendor = (row.original as any).vendor
|
||||
return vendor?.company_name || 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() : "—"
|
||||
cell: ({ row }) => formatDate((row.original as any).expense_date),
|
||||
},
|
||||
{
|
||||
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",
|
||||
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => {
|
||||
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 (
|
||||
<Badge variant={status === "paid" ? "default" : "secondary"}>
|
||||
{status || "—"}
|
||||
<Badge variant={variantMap[status] ?? "outline"}>
|
||||
{formatEnum(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(),
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -19,12 +19,12 @@ export default function PurchaseOrdersPage() {
|
||||
onRowClick={(row) => router.push(`/purchase/purchase-order/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog classNames={{dialogContent:"min-w-6xl"}} title="Purchase Order">
|
||||
{(resourceId) => (
|
||||
<FormDialog classNames={{ dialogContent: "min-w-6xl" }} title="Purchase Order">
|
||||
{(resourceId, { close }) => (
|
||||
<PurchaseOrderForm
|
||||
resourceId={resourceId}
|
||||
initialData={selectedItem}
|
||||
onSuccess={invalidateQuery}
|
||||
onSuccess={() => { invalidateQuery(); close()}}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
|
||||
@ -3,6 +3,7 @@ import { getServerApi } from '@garage/api/server'
|
||||
import { EstimateActions } from '@/modules/estimates/estimate-actions'
|
||||
import { EstimateProvider } from '@/modules/estimates/estimate-context'
|
||||
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 React from 'react'
|
||||
|
||||
@ -12,9 +13,9 @@ export default async function layout(props: {
|
||||
}) {
|
||||
const { id } = await props.params
|
||||
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 estimateLabel = estimateData?.estimate_number
|
||||
? `${estimateData.estimate_number}${estimateData.title ? ` — ${estimateData.title}` : ''}`
|
||||
@ -33,6 +34,7 @@ export default async function layout(props: {
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CreateInvoiceFromEstimateButton />
|
||||
<CreateJobCardFromEstimateButton />
|
||||
<EstimateActions estimateId={id} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -8,9 +8,9 @@ import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
|
||||
export default async function page(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
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) {
|
||||
return <div className="text-muted-foreground p-4">Estimate not found.</div>
|
||||
|
||||
@ -4,6 +4,7 @@ import { InvoiceActions } from '@/modules/invoices/invoice-actions'
|
||||
import { InvoiceProvider } from '@/modules/invoices/invoice-context'
|
||||
import { ReceiptIcon } from 'lucide-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 }) {
|
||||
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'
|
||||
|
||||
return (
|
||||
<InvoiceProvider invoice={{ id, label: title }}>
|
||||
<InvoiceProvider invoice={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="/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={[
|
||||
{
|
||||
href: `/sales/invoice/${id}`,
|
||||
label: 'Details'
|
||||
},
|
||||
|
||||
{
|
||||
href: `/sales/invoice/${id}/documents`,
|
||||
label: 'Documents'
|
||||
|
||||
@ -3,7 +3,10 @@ import { InvoiceGeneralInfo } from '@/modules/invoices/invoice-general-info'
|
||||
import { InvoicePartsSection } from '@/modules/invoices/invoice-parts-section'
|
||||
import { InvoiceServicesSection } from '@/modules/invoices/invoice-services-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 InvoicePaymentsSection from '@/modules/invoices/invoice-payments-section'
|
||||
|
||||
|
||||
export default async function InvoiceDetailPage(props: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await props.params
|
||||
@ -18,10 +21,12 @@ export default async function InvoiceDetailPage(props: { params: Promise<{ id: s
|
||||
return (
|
||||
<DashboardPage header={null}>
|
||||
<div className="grid gap-6">
|
||||
<InvoiceGeneralInfo invoice={data} />
|
||||
<InvoiceGeneralInfo />
|
||||
<InvoicePartsSection parts={data.invoice_parts} />
|
||||
<InvoiceServicesSection services={data.invoice_services} />
|
||||
<InvoiceExpensesSection expenses={data.invoice_expenses} />
|
||||
<InvoicePaymentsSection></InvoicePaymentsSection>
|
||||
<InvoiceTotalsSummary />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
|
||||
@ -30,7 +30,7 @@ export default function InvoicesPage() {
|
||||
onRowClick={(row) => router.push(`/sales/invoice/${(row as any).id}`)}
|
||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||
actions: (
|
||||
<FormDialog title="Invoice">
|
||||
<FormDialog classNames={{dialogContent:'lg:min-w-6xl'}} title="Invoice">
|
||||
{(resourceId) => (
|
||||
<InvoiceForm
|
||||
resourceId={resourceId}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -12,7 +12,7 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
||||
const { id } = await props.params
|
||||
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 status = jobCard?.status || 'draft'
|
||||
@ -35,42 +35,53 @@ export default async function JobCardDetailLayout(props: { params: Promise<{ id:
|
||||
href: `/sales/job-cards/${id}`,
|
||||
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`,
|
||||
label: `Parts (${jobCard?.parts_count || 0})`
|
||||
},
|
||||
{
|
||||
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}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { getServerApi } from '@garage/api/server'
|
||||
import { JobCardGeneralInfo } from '@/modules/job-cards/job-card-general-info'
|
||||
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 }> }) {
|
||||
const { id } = await props.params
|
||||
const api = await getServerApi()
|
||||
const jobCard = await api.jobCards.show(id)
|
||||
const data = (jobCard as any)?.data ?? jobCard
|
||||
const response = await api.jobCards.show(id)
|
||||
const data = response.data
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-muted-foreground">Job card not found.</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"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"
|
||||
@ -33,6 +34,7 @@ export default function JobCardPartsPage({
|
||||
const { id: jobCardId } = use(params)
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const queryKey = ["job-card-parts", jobCardId]
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@ -45,7 +47,7 @@ export default function JobCardPartsPage({
|
||||
|
||||
const rows = (data as any)?.data ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
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" />,
|
||||
cell: ({ row }) => row.original.tax || "—",
|
||||
cell: ({ row }) => row.original.tax_id ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "department.name",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"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"
|
||||
@ -26,6 +27,8 @@ import { JobCardServiceForm } from "@/modules/job-cards/job-card-service-form"
|
||||
import { formatDate } from "@/shared/utils/formatters"
|
||||
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({
|
||||
params,
|
||||
}: {
|
||||
@ -34,6 +37,7 @@ export default function JobCardServicesPage({
|
||||
const { id: jobCardId } = use(params)
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const queryKey = ["job-card-services", jobCardId]
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@ -46,7 +50,7 @@ export default function JobCardServicesPage({
|
||||
|
||||
const rows = (data as any)?.data ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey }).then(() => router.refresh())
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
const confirmed = await confirm({
|
||||
@ -125,9 +129,17 @@ export default function JobCardServicesPage({
|
||||
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" />,
|
||||
cell: ({ row }) => row.original.tax || "—",
|
||||
cell: ({ row }) => row.original.tax_id ?? "—",
|
||||
},
|
||||
{
|
||||
accessorKey: "department.name",
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import { SettingsForm } from "@/modules/settings/company/settings-form"
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<DashboardPage headerProps={{title:'Company Settings'}}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Company Settings</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage your workshop profile, contact details, and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsForm />
|
||||
</div>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ export const navGroups: NavGroup[] = [
|
||||
href: "/calendars",
|
||||
icon: <CalendarIcon />,
|
||||
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 /> },
|
||||
],
|
||||
},
|
||||
@ -144,10 +144,10 @@ export const navGroups: NavGroup[] = [
|
||||
{ title: "Employees", href: "/productivity/employees", icon: <UsersIcon /> },
|
||||
{ title: "Time Clocks", href: "/productivity/time-clocks", icon: <TimerIcon /> },
|
||||
{ title: "Time Sheets", href: "/productivity/timesheet", icon: <ClockIcon /> },
|
||||
{ title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
|
||||
{ title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
|
||||
{ title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
|
||||
{ title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
|
||||
// { title: "Payroll", href: "/productivity/payroll", icon: <WalletIcon /> },
|
||||
// { title: "Payments Made", href: "/productivity/employee-payments-made", icon: <HandCoinsIcon /> },
|
||||
// { title: "Shop Calendars", href: "/productivity/shop-calendars", icon: <CalendarDaysIcon /> },
|
||||
// { title: "Shop Timing", href: "/productivity/shop-timings", icon: <Clock3Icon /> },
|
||||
{ title: "Holidays", href: "/productivity/holidays", icon: <CalendarIcon /> },
|
||||
{ title: "Tasks", href: "/productivity/tasks", icon: <ListTodoIcon /> },
|
||||
],
|
||||
@ -161,7 +161,7 @@ export const navGroups: NavGroup[] = [
|
||||
{ title: "Parts", href: "/items/parts", icon: <WrenchIcon /> },
|
||||
{ title: "Expense Item", href: "/items/expense-item", icon: <WalletIcon /> },
|
||||
{ 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 /> },
|
||||
],
|
||||
},
|
||||
@ -177,9 +177,9 @@ export const navGroups: NavGroup[] = [
|
||||
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
|
||||
{ title: "Make & Models", href: "/settings/make-and-models", icon: <CarIcon /> },
|
||||
{ title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> },
|
||||
{ title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
|
||||
{ title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> },
|
||||
{ title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> },
|
||||
// { title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
|
||||
// { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> },
|
||||
// { title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
50
apps/dashboard/modules/bills/bill-actions.tsx
Normal file
50
apps/dashboard/modules/bills/bill-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
apps/dashboard/modules/bills/bill-context.tsx
Normal file
75
apps/dashboard/modules/bills/bill-context.tsx
Normal 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)
|
||||
}
|
||||
80
apps/dashboard/modules/bills/bill-expenses-section.tsx
Normal file
80
apps/dashboard/modules/bills/bill-expenses-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/dashboard/modules/bills/bill-form-summary.tsx
Normal file
97
apps/dashboard/modules/bills/bill-form-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -5,24 +5,39 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Rhform,
|
||||
RhfAsyncSelectField,
|
||||
RhfSelectField,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAutoGenerateField,
|
||||
RhfDateField,
|
||||
} from "@/shared/components/form"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toId, toRelation } from "@/shared/lib/utils"
|
||||
import { getTodayDate, toId, toRelation } from "@/shared/lib/utils"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, PURCHASE_ORDER_ROUTES, VENDOR_ROUTES } from "@garage/api"
|
||||
import {
|
||||
BillStatus,
|
||||
DiscountType,
|
||||
BILL_ROUTES,
|
||||
DEPARTMENT_ROUTES,
|
||||
JOB_CARD_ROUTES,
|
||||
PAYMENT_TERM_ROUTES,
|
||||
PURCHASE_ORDER_ROUTES,
|
||||
VENDOR_ROUTES,
|
||||
TAX_ROUTES,
|
||||
} from "@garage/api"
|
||||
import { toast } from "sonner"
|
||||
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
|
||||
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import { billFormSchema, type BillFormValues } from "./bill.schema"
|
||||
import { BillFormSummary } from "./bill-form-summary"
|
||||
|
||||
export type BillFormProps = {
|
||||
resourceId?: string | null
|
||||
@ -31,16 +46,20 @@ export type BillFormProps = {
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: BillFormValues = {
|
||||
title: "",
|
||||
vendor: null,
|
||||
vendor_address: null,
|
||||
purchase_order: null,
|
||||
job_card: null,
|
||||
payment_term: null,
|
||||
department: null,
|
||||
title: "",
|
||||
tax: null,
|
||||
bill_number: "",
|
||||
bill_date: "",
|
||||
bill_date: getTodayDate(),
|
||||
bill_due_date: "",
|
||||
status: "draft",
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
notes: "",
|
||||
part_items: [],
|
||||
service_items: [],
|
||||
@ -49,12 +68,17 @@ const DEFAULT_VALUES: BillFormValues = {
|
||||
|
||||
const STATUS_OPTIONS = BillStatus.map((value) => ({
|
||||
value,
|
||||
label: value.replaceAll("_", " "),
|
||||
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
|
||||
value,
|
||||
label: value.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
|
||||
label: item.name ?? item.title ?? `#${item.id}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
@ -63,36 +87,43 @@ function mapToFormValues(data: unknown): BillFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
purchase_order: toRelation(d.purchase_order_id, d.purchase_order_number ?? d.purchase_order_title),
|
||||
job_card: toRelation(d.job_card_id, d.job_card_number ?? d.job_card_name),
|
||||
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
|
||||
department: toRelation(d.department_id, d.department_name),
|
||||
title: d.title || "",
|
||||
vendor: toRelation(d.vendor_id, d.vendor?.name ?? d.vendor_name),
|
||||
vendor_address: toRelation(d.vendor_address_id, d.vendor_address?.address),
|
||||
purchase_order: toRelation(d.purchase_order_id, d.purchase_order?.order_number ?? d.purchase_order_number),
|
||||
job_card: toRelation(d.job_card_id, d.job_card?.order_number ?? d.job_card_number),
|
||||
payment_term: toRelation(d.payment_terms_id, d.payment_terms?.name ?? d.payment_terms_name),
|
||||
department: toRelation(d.department_id, d.department?.name ?? d.department_name),
|
||||
tax: toRelation(d.tax_id, d.tax?.name ? `${d.tax.name} (${d.tax.rate}%)` : d.tax_title),
|
||||
bill_number: d.bill_number || "",
|
||||
bill_date: d.bill_date || "",
|
||||
bill_due_date: d.bill_due_date || "",
|
||||
bill_date: d.bill_date ? d.bill_date.split("T")[0] : "",
|
||||
bill_due_date: d.bill_due_date ? d.bill_due_date.split("T")[0] : "",
|
||||
status: d.status || "draft",
|
||||
discount: d.discount_type || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
notes: d.notes || "",
|
||||
part_items: (d.parts ?? []).map((p: any) => ({
|
||||
part_id: p.part_id ?? p.id,
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
title: p.part?.name ?? p.part_name ?? p.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
service_items: (d.services ?? []).map((s: any) => ({
|
||||
service_id: s.service_id ?? s.id,
|
||||
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
|
||||
title: s.service?.name ?? s.service_name ?? s.title ?? "",
|
||||
quantity: Number(s.quantity) || 1,
|
||||
rate: Number(s.rate) || 0,
|
||||
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
|
||||
description: s.description ?? "",
|
||||
})),
|
||||
expense_items: (d.expenses ?? []).map((e: any) => ({
|
||||
expense_id: e.expense_id ?? e.id,
|
||||
title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
|
||||
title: e.expense?.title ?? e.expense_title ?? e.title ?? "",
|
||||
quantity: Number(e.quantity) || 1,
|
||||
rate: Number(e.rate) || 0,
|
||||
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
|
||||
description: e.description ?? "",
|
||||
})),
|
||||
}
|
||||
@ -102,36 +133,61 @@ function mapFormToPayload(values: BillFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
vendor_id: toId(values.vendor),
|
||||
vendor_address_id: toId(values.vendor_address),
|
||||
purchase_order_id: toId(values.purchase_order),
|
||||
job_card_id: toId(values.job_card),
|
||||
payment_terms_id: toId(values.payment_term),
|
||||
department_id: toId(values.department),
|
||||
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
|
||||
bill_number: values.bill_number || undefined,
|
||||
bill_date: values.bill_date || undefined,
|
||||
bill_due_date: values.bill_due_date || undefined,
|
||||
status: values.status || undefined,
|
||||
discount_type: values.discount || undefined,
|
||||
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
||||
notes: values.notes || undefined,
|
||||
part_items: (values.part_items ?? []).map((item) => ({
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
service_items: (values.service_items ?? []).map((item) => ({
|
||||
service_id: item.service_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
expense_items: (values.expense_items ?? []).map((item) => ({
|
||||
expense_id: item.expense_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transaction-level discount field (conditional) ──
|
||||
|
||||
function TransactionDiscountField() {
|
||||
const { watch } = useFormContext<BillFormValues>()
|
||||
const discount = watch("discount")
|
||||
if (discount !== "transaction_level") return null
|
||||
return (
|
||||
<RhfTextField
|
||||
name="discount_amount"
|
||||
label="Discount Amount"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
@ -140,22 +196,24 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
queryKey: [BILL_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const discount = form.watch("discount")
|
||||
const isLineItemDiscount = discount === "line_item_level"
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: BillFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
const promise = (isEditing && resourceId
|
||||
? api.bills.update(resourceId, payload)
|
||||
: api.bills.create(payload)
|
||||
|
||||
: api.bills.create(payload)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating bill..." : "Creating bill...",
|
||||
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
|
||||
error: isEditing ? "Failed to update bill" : "Failed to create bill",
|
||||
})
|
||||
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -169,25 +227,59 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>{isEditing ? "Failed to update bill" : "Failed to create bill"}</AlertTitle>
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update bill" : "Failed to create bill"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
|
||||
{/* ── Main column (9/12) ── */}
|
||||
<div className="lg:col-span-9">
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="bill_date" label="Bill Date" type="date" />
|
||||
<RhfTextField name="bill_due_date" label="Due Date" type="date" />
|
||||
<RhfSelectField name="discount" label="Discount Type" options={DISCOUNT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
<TransactionDiscountField />
|
||||
|
||||
<PartsSelectorField<BillFormValues, "part_items"> name="part_items" showDiscount={isLineItemDiscount} />
|
||||
<ServicesSelectorField<BillFormValues, "service_items"> name="service_items" showDiscount={isLineItemDiscount} />
|
||||
<ExpenseItemsSelectorField<BillFormValues, "expense_items"> name="expense_items" showDiscount={isLineItemDiscount} />
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Bill" : "Create Bill")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
{/* ── 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 className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
@ -197,6 +289,20 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="tax"
|
||||
label="Tax"
|
||||
placeholder="Select tax rate"
|
||||
queryKey={[TAX_ROUTES.INDEX]}
|
||||
listFn={() => api.taxes.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
@ -206,28 +312,29 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
mapOption={mapLookupOption}
|
||||
{...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
|
||||
name="job_card"
|
||||
label="Job Card"
|
||||
placeholder="Select job card"
|
||||
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.order_number || item.estimate_number || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_term"
|
||||
label="Payment Term"
|
||||
placeholder="Select payment term"
|
||||
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentTerms.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="purchase_order"
|
||||
@ -241,30 +348,20 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
||||
})}
|
||||
{...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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<BillFormSummary />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
180
apps/dashboard/modules/bills/bill-general-info.tsx
Normal file
180
apps/dashboard/modules/bills/bill-general-info.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
apps/dashboard/modules/bills/bill-parts-section.tsx
Normal file
80
apps/dashboard/modules/bills/bill-parts-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
apps/dashboard/modules/bills/bill-payments-section.tsx
Normal file
157
apps/dashboard/modules/bills/bill-payments-section.tsx
Normal 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(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
80
apps/dashboard/modules/bills/bill-services-section.tsx
Normal file
80
apps/dashboard/modules/bills/bill-services-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
apps/dashboard/modules/bills/bill-status-badge.tsx
Normal file
85
apps/dashboard/modules/bills/bill-status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/dashboard/modules/bills/bill-totals-summary.tsx
Normal file
117
apps/dashboard/modules/bills/bill-totals-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,7 @@ const billPartItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -17,6 +18,7 @@ const billServiceItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -25,21 +27,33 @@ const billExpenseItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const billFormSchema = z.object({
|
||||
// ── Required ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
|
||||
// ── Relations ──
|
||||
vendor: relationFieldSchema,
|
||||
vendor_address: relationFieldSchema,
|
||||
purchase_order: relationFieldSchema,
|
||||
job_card: relationFieldSchema,
|
||||
payment_term: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
title: z.string().min(1, "Title is required"),
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Optional fields ──
|
||||
bill_number: z.string().optional(),
|
||||
bill_date: z.string().optional(),
|
||||
bill_due_date: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
discount: z.string().optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
|
||||
// ── Line items ──
|
||||
part_items: z.array(billPartItemSchema).optional(),
|
||||
service_items: z.array(billServiceItemSchema).optional(),
|
||||
expense_items: z.array(billExpenseItemSchema).optional(),
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -8,10 +8,13 @@ import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfCheckboxField,
|
||||
} 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 { useAuthApi } from "@/shared/useApi"
|
||||
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 { 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"
|
||||
|
||||
// ── Constants ──
|
||||
@ -48,10 +57,19 @@ export type ExpenseItemFormProps = {
|
||||
const DEFAULT_VALUES: ExpenseItemFormValues = {
|
||||
item_type: "Expense",
|
||||
item_name: "",
|
||||
sku: "",
|
||||
item_code: "",
|
||||
description: "",
|
||||
category: null,
|
||||
unit_type: null,
|
||||
department: null,
|
||||
purchase_information: true,
|
||||
purchase_price: undefined,
|
||||
purchase_chart_of_account: "",
|
||||
purchase_information: true,
|
||||
purchase_preferred_vendor: null,
|
||||
sales_information: false,
|
||||
selling_price: undefined,
|
||||
sales_chart_of_account: "",
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
@ -63,10 +81,22 @@ function mapToFormValues(data: unknown): ExpenseItemFormValues {
|
||||
return {
|
||||
item_type: d.item_type || "Expense",
|
||||
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),
|
||||
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_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,
|
||||
}
|
||||
}
|
||||
@ -75,10 +105,19 @@ function mapFormToPayload(values: ExpenseItemFormValues) {
|
||||
return {
|
||||
item_type: values.item_type,
|
||||
item_name: values.item_name,
|
||||
sku: values.sku || undefined,
|
||||
item_code: values.item_code || undefined,
|
||||
description: values.description || undefined,
|
||||
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_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,
|
||||
}
|
||||
}
|
||||
@ -129,6 +168,7 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="item_type"
|
||||
@ -144,6 +184,27 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
|
||||
/>
|
||||
</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 className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Category</span>
|
||||
@ -160,6 +221,37 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
|
||||
/>
|
||||
</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">
|
||||
<RhfTextField
|
||||
name="purchase_price"
|
||||
@ -174,16 +266,41 @@ export function ExpenseItemForm({ resourceId, initialData, onSuccess }: ExpenseI
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<RhfCheckboxField
|
||||
name="purchase_information"
|
||||
label="Purchase Information"
|
||||
<RhfAsyncSelectField
|
||||
name="purchase_preferred_vendor"
|
||||
label="Preferred Vendor"
|
||||
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
|
||||
name="is_active"
|
||||
label="Active"
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
|
||||
@ -7,10 +7,22 @@ export const relationFieldSchema = z
|
||||
export const expenseItemFormSchema = z.object({
|
||||
item_type: z.string().min(1, "Item type 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,
|
||||
unit_type: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
// Purchase
|
||||
purchase_information: z.boolean().default(true),
|
||||
purchase_price: z.coerce.number().min(0).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),
|
||||
})
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ type ExpenseLineItem = {
|
||||
title: string
|
||||
quantity: number
|
||||
rate: number
|
||||
chart_of_account?: string
|
||||
discount_amount?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
@ -34,6 +36,8 @@ export type ExpenseItemsSelectorFieldProps<
|
||||
name: TName & (TValues[TName] extends ExpenseItemsFieldConstraint ? TName : never)
|
||||
label?: string
|
||||
triggerLabel?: string
|
||||
showChartOfAccount?: boolean
|
||||
showDiscount?: boolean
|
||||
}
|
||||
|
||||
export function ExpenseItemsSelectorField<
|
||||
@ -43,6 +47,8 @@ export function ExpenseItemsSelectorField<
|
||||
name,
|
||||
label = "Expense Items",
|
||||
triggerLabel = "Add Expense Items",
|
||||
showChartOfAccount = false,
|
||||
showDiscount = false,
|
||||
}: ExpenseItemsSelectorFieldProps<TValues, TName>) {
|
||||
return (
|
||||
<RhfResourceField<TValues, TName, ExpenseItemsClient>
|
||||
@ -69,6 +75,7 @@ export function ExpenseItemsSelectorField<
|
||||
title: r.item_name || "",
|
||||
quantity: 1,
|
||||
rate: Number(r.purchase_price) || 0,
|
||||
chart_of_account: r.purchase_chart_of_account ? String(r.purchase_chart_of_account) : "",
|
||||
description: "",
|
||||
} as any
|
||||
}}
|
||||
@ -79,6 +86,8 @@ export function ExpenseItemsSelectorField<
|
||||
<TableHead>Expense Item</TableHead>
|
||||
<TableHead className="w-24">Qty</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 className="w-12" />
|
||||
</TableRow>
|
||||
@ -110,6 +119,32 @@ export function ExpenseItemsSelectorField<
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
value={item.description ?? ""}
|
||||
|
||||
50
apps/dashboard/modules/expenses/expense-actions.tsx
Normal file
50
apps/dashboard/modules/expenses/expense-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
apps/dashboard/modules/expenses/expense-context.tsx
Normal file
27
apps/dashboard/modules/expenses/expense-context.tsx
Normal 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)
|
||||
}
|
||||
62
apps/dashboard/modules/expenses/expense-form-summary.tsx
Normal file
62
apps/dashboard/modules/expenses/expense-form-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -5,29 +5,49 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
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 { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { getTodayDate, toRelation, toId } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
expenseFormSchema,
|
||||
type ExpenseFormValues,
|
||||
} from "./expense.schema"
|
||||
import { EXPENSE_ROUTES, JOB_CARD_ROUTES, VENDOR_ROUTES, DEPARTMENT_ROUTES, ExpenseStatus } from "@garage/api"
|
||||
import { getFullName } from "@/shared/utils/getFullName"
|
||||
import {
|
||||
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 ──
|
||||
|
||||
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,
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
@ -47,11 +67,16 @@ const DEFAULT_VALUES: ExpenseFormValues = {
|
||||
category: null,
|
||||
vendor: null,
|
||||
department: null,
|
||||
tax: null,
|
||||
title: "",
|
||||
invoice_number: "",
|
||||
expense_date: "",
|
||||
expense_date: getTodayDate(),
|
||||
notes: "",
|
||||
status: "open",
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
labels: [],
|
||||
items: [],
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@ -60,15 +85,32 @@ function mapToFormValues(data: unknown): ExpenseFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
job_card: toRelation(d.job_card_id, d.job_card_name),
|
||||
category: toRelation(d.category_id, d.category_name),
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
department: toRelation(d.department_id, d.department_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 ?? d.category?.title ?? d.category_name),
|
||||
vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_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 || "",
|
||||
invoice_number: d.invoice_number || "",
|
||||
expense_date: d.expense_date || "",
|
||||
expense_date: d.expense_date ? d.expense_date.split("T")[0] : "",
|
||||
notes: d.notes || "",
|
||||
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),
|
||||
vendor_id: toId(values.vendor),
|
||||
department_id: toId(values.department),
|
||||
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
|
||||
title: values.title,
|
||||
invoice_number: values.invoice_number || undefined,
|
||||
expense_date: values.expense_date || undefined,
|
||||
notes: values.notes || 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) => ({
|
||||
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 }
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormProps) {
|
||||
@ -105,15 +180,19 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
queryKey: [EXPENSE_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const discount = form.watch("discount")
|
||||
const isLineItemDiscount = discount === "line_item_level"
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: ExpenseFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = isEditing && resourceId
|
||||
? api.expenses.update(resourceId, payload)
|
||||
: api.expenses.create(payload)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.expenses.update(resourceId, payload as any)
|
||||
: api.expenses.create(payload as any)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating expense..." : "Creating expense...",
|
||||
success: isEditing ? "Expense updated successfully" : "Expense created successfully",
|
||||
@ -139,29 +218,87 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-9">
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="Enter expense title" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="invoice_number" label="Invoice Number" placeholder="INV-001" />
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<RhfTextField name="expense_date" label="Expense Date" placeholder="YYYY-MM-DD" type="date" />
|
||||
</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
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={(op: any) => ({ value: String(op.id), label: getFullName(op)})}
|
||||
mapOption={mapVendorOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
@ -171,40 +308,46 @@ export function ExpenseForm({ resourceId, initialData, onSuccess }: ExpenseFormP
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="job_card"
|
||||
label="Job Card"
|
||||
placeholder="Select job card"
|
||||
queryKey={[JOB_CARD_ROUTES.INDEX]}
|
||||
listFn={() => api.jobCards.list()}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.order_number ?? item.estimate_number ?? `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Category</span>
|
||||
<InventoryCategoryCrudDialog />
|
||||
</div>
|
||||
<RhfAsyncSelectField
|
||||
name="category"
|
||||
label="Category"
|
||||
label=""
|
||||
placeholder="Select category"
|
||||
queryKey={[EXPENSE_ROUTES.ITEMS]}
|
||||
listFn={() => api.expenses.listItems()}
|
||||
queryKey={[INVENTORY_CATEGORY_ROUTES.INDEX]}
|
||||
listFn={() => api.inventoryCategories.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextField name="invoice_number" label="Invoice Number" placeholder="INV-001" />
|
||||
|
||||
<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>
|
||||
<RhfLabelPickerField name="labels" label="Labels" />
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<ExpenseFormSummary />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
235
apps/dashboard/modules/expenses/expense-general-info.tsx
Normal file
235
apps/dashboard/modules/expenses/expense-general-info.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
apps/dashboard/modules/expenses/expense-items-section.tsx
Normal file
133
apps/dashboard/modules/expenses/expense-items-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
apps/dashboard/modules/expenses/expense-payments-section.tsx
Normal file
157
apps/dashboard/modules/expenses/expense-payments-section.tsx
Normal 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(),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -4,12 +4,29 @@ const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.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({
|
||||
// ── Relations ──
|
||||
job_card: relationFieldSchema,
|
||||
category: relationFieldSchema,
|
||||
vendor: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Basic info ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
@ -17,6 +34,13 @@ const expenseFormSchema = z.object({
|
||||
expense_date: z.string().optional(),
|
||||
notes: 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>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -9,7 +10,10 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import { InvoiceEditForm } from "./invoice-edit-form"
|
||||
import { useInvoice } from "./invoice-context"
|
||||
|
||||
type InvoiceActionsProps = {
|
||||
invoiceId: string
|
||||
@ -18,9 +22,11 @@ type InvoiceActionsProps = {
|
||||
export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/invoice/${invoiceId}/edit`)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const invoice = useInvoice()
|
||||
const handleEditSuccess = () => {
|
||||
setIsEditOpen(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
@ -29,6 +35,7 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
@ -36,15 +43,31 @@ export function InvoiceActions({ invoiceId }: InvoiceActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{/* <DropdownMenuItem onClick={() => setIsEditOpen(true)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* <Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Invoice</DialogTitle>
|
||||
</DialogHeader>
|
||||
<InvoiceEditForm
|
||||
initialData={{
|
||||
|
||||
}}
|
||||
resourceId={invoiceId}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { ApiResponse } from "@garage/api"
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type InvoiceContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
id: number | string
|
||||
subject?: string | null
|
||||
invoice_number?: string | null
|
||||
discount?: string | null
|
||||
sub_total?: number | string | null
|
||||
total?: number | string | null
|
||||
payments_recieved?: number | string | null
|
||||
received_payment?: number | string | null
|
||||
balance_due?: number | string | null
|
||||
amount?: number | string | null
|
||||
invoice_parts?: { quantity?: string | number; rate?: string | number }[]
|
||||
invoice_services?: { quantity?: string | number; rate?: string | number }[]
|
||||
invoice_expenses?: { quantity?: string | number; rate?: string | number }[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const InvoiceContext = createContext<InvoiceContextValue | null>(null)
|
||||
|
||||
192
apps/dashboard/modules/invoices/invoice-edit-form.tsx
Normal file
192
apps/dashboard/modules/invoices/invoice-edit-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
apps/dashboard/modules/invoices/invoice-form-summary.tsx
Normal file
104
apps/dashboard/modules/invoices/invoice-form-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
@ -29,19 +30,19 @@ import {
|
||||
import {
|
||||
INVOICE_ROUTES,
|
||||
DEPARTMENT_ROUTES,
|
||||
ESTIMATE_ROUTES,
|
||||
PAYMENT_TERM_ROUTES,
|
||||
INVOICE_SEQUENCE_ROUTES,
|
||||
PAYMENT_MODE_ROUTES,
|
||||
CUSTOMER_ROUTES,
|
||||
TAX_ROUTES,
|
||||
InvoiceStatus,
|
||||
InvoiceDiscount,
|
||||
} from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { InvoiceSequenceCrudDialog } from "./invoice-sequence-crud-dialog"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||
import { ServicesSelectorField } from "@/modules/services/services-selector-field"
|
||||
import { ExpenseItemsSelectorField } from "@/modules/expense-items/expense-items-selector-field"
|
||||
import { InvoiceFormSummary } from "./invoice-form-summary"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
@ -78,12 +79,14 @@ const DEFAULT_VALUES: InvoiceFormValues = {
|
||||
invoice_to: null,
|
||||
invoice_number: "",
|
||||
invoice_title: "",
|
||||
invoice_date: "",
|
||||
invoice_date: new Date().toISOString().split("T")[0],
|
||||
due_date: "",
|
||||
status: "draft",
|
||||
kms_in: undefined,
|
||||
has_insurance: false,
|
||||
discount: "no",
|
||||
discount_amount: undefined,
|
||||
tax: null,
|
||||
deposit_to: "",
|
||||
notes: "",
|
||||
terms_and_conditions: "",
|
||||
@ -116,6 +119,8 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
kms_in: d.kms_in ? Number(d.kms_in) : undefined,
|
||||
has_insurance: d.has_insurance ?? false,
|
||||
discount: d.discount || "no",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
tax: toRelation(d.tax_id, d.tax_title ?? d.tax?.title),
|
||||
deposit_to: d.deposit_to || "",
|
||||
notes: d.notes || "",
|
||||
terms_and_conditions: d.terms_and_conditions || "",
|
||||
@ -124,6 +129,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: p.part?.title ?? p.title ?? "",
|
||||
quantity: Number(p.quantity) || 1,
|
||||
rate: Number(p.rate) || 0,
|
||||
discount_amount: p.discount_amount != null ? Number(p.discount_amount) : undefined,
|
||||
description: p.description ?? "",
|
||||
})),
|
||||
services: (d.invoice_services ?? d.services ?? []).map((s: any) => ({
|
||||
@ -131,6 +137,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
|
||||
quantity: Number(s.quantity) || 1,
|
||||
rate: Number(s.rate) || 0,
|
||||
discount_amount: s.discount_amount != null ? Number(s.discount_amount) : undefined,
|
||||
description: s.description ?? "",
|
||||
})),
|
||||
expense_items: (d.invoice_expenses ?? d.expenses ?? []).map((e: any) => ({
|
||||
@ -138,6 +145,7 @@ function mapToFormValues(data: unknown): InvoiceFormValues {
|
||||
title: e.expense?.item_name ?? e.item_name ?? e.title ?? "",
|
||||
quantity: Number(e.quantity) || 1,
|
||||
rate: Number(e.rate) || 0,
|
||||
discount_amount: e.discount_amount != null ? Number(e.discount_amount) : undefined,
|
||||
description: e.description ?? "",
|
||||
})),
|
||||
}
|
||||
@ -163,6 +171,8 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
kms_in: values.kms_in || undefined,
|
||||
has_insurance: values.has_insurance,
|
||||
discount: values.discount || undefined,
|
||||
discount_amount: values.discount === "transaction_level" ? (values.discount_amount ?? 0) : undefined,
|
||||
tax_id: toId(values.tax) ? Number(toId(values.tax)) : undefined,
|
||||
deposit_to: values.deposit_to || undefined,
|
||||
notes: values.notes || undefined,
|
||||
terms_and_conditions: values.terms_and_conditions || undefined,
|
||||
@ -170,18 +180,21 @@ function mapFormToPayload(values: InvoiceFormValues) {
|
||||
part_id: item.part_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
services: (values.services ?? []).map((item) => ({
|
||||
service_id: item.service_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
expenses: (values.expense_items ?? []).map((item) => ({
|
||||
expense_id: item.expense_id,
|
||||
quantity: item.quantity,
|
||||
rate: item.rate,
|
||||
discount_amount: values.discount === "line_item_level" ? (item.discount_amount ?? 0) : undefined,
|
||||
description: item.description || undefined,
|
||||
})),
|
||||
}
|
||||
@ -213,6 +226,25 @@ function InsurerField() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Transaction-level discount amount field ──
|
||||
|
||||
function TransactionDiscountField() {
|
||||
const { watch, register } = useFormContext<InvoiceFormValues>()
|
||||
const discount = watch("discount")
|
||||
|
||||
if (discount !== "transaction_level") return null
|
||||
|
||||
return (
|
||||
<RhfTextField
|
||||
|
||||
name="discount_amount"
|
||||
label="Discount Amount"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormProps) {
|
||||
@ -227,6 +259,9 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const discount = form.watch("discount")
|
||||
const isLineItemDiscount = discount === "line_item_level"
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: InvoiceFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
@ -258,6 +293,10 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
|
||||
{/* ── Main column (8/12) ── */}
|
||||
<div className="lg:col-span-9">
|
||||
<FieldGroup>
|
||||
<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">
|
||||
<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 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="due_date" label="Due Date" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "customer"> name="customer" />
|
||||
<RhfVehicleSelectField name="vehicle" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfCustomerSelectField<InvoiceFormValues, "invoice_to">
|
||||
name="invoice_to"
|
||||
label="Invoice To"
|
||||
placeholder="Select billing contact..."
|
||||
<RhfAsyncSelectField
|
||||
name="tax"
|
||||
label="Tax"
|
||||
placeholder="Select tax rate"
|
||||
queryKey={[TAX_ROUTES.INDEX]}
|
||||
listFn={() => api.taxes.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title ? `${item.title} (${item.rate}%)` : `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
@ -296,21 +366,7 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="estimate"
|
||||
label="Estimate"
|
||||
placeholder="Select estimate"
|
||||
queryKey={[ESTIMATE_ROUTES.INDEX]}
|
||||
listFn={() => api.estimates.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title||item.estimate_number || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="payment_terms"
|
||||
label="Payment Terms"
|
||||
@ -320,12 +376,15 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
mapOption={mapLookupOption}
|
||||
{...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
|
||||
name="invoice_sequence"
|
||||
label="Invoice Sequence"
|
||||
label=""
|
||||
placeholder="Select sequence"
|
||||
queryKey={[INVOICE_SEQUENCE_ROUTES.INDEX]}
|
||||
listFn={() => api.invoiceSequences.list()}
|
||||
@ -335,39 +394,20 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="kms_in" label="KMs In" type="number" placeholder="e.g. 50000" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="payment_mode"
|
||||
label="Payment Mode"
|
||||
placeholder="Select payment mode"
|
||||
queryKey={[PAYMENT_MODE_ROUTES.INDEX]}
|
||||
listFn={() => api.paymentModes.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="deposit_to" label="Deposit To" placeholder="e.g. Main Account" />
|
||||
</div>
|
||||
|
||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
||||
<InsurerField />
|
||||
|
||||
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes" rows={3} />
|
||||
<RhfTextareaField name="terms_and_conditions" label="Terms & Conditions" rows={3} />
|
||||
|
||||
<PartsSelectorField<InvoiceFormValues, "parts"> name="parts" />
|
||||
<ServicesSelectorField<InvoiceFormValues, "services"> name="services" />
|
||||
<ExpenseItemsSelectorField<InvoiceFormValues, "expense_items"> name="expense_items" />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Invoice" : "Create Invoice")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<InvoiceFormSummary />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
"use client"
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
@ -5,11 +6,12 @@ import {
|
||||
Users,
|
||||
Car,
|
||||
Building2,
|
||||
CircleDollarSign,
|
||||
Clock,
|
||||
Mail,
|
||||
Phone,
|
||||
DollarSign,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
TimerIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
@ -19,45 +21,9 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { formatDate, formatCurrency, formatEnum, formatNumber } from "@/shared/utils/formatters"
|
||||
|
||||
type InvoiceData = {
|
||||
id?: number
|
||||
subject?: string
|
||||
invoice_number?: string
|
||||
invoice_title?: string
|
||||
invoice_date?: string
|
||||
due_date?: string
|
||||
status?: string
|
||||
notes?: string
|
||||
terms_and_conditions?: string
|
||||
customer_name?: string
|
||||
customer_id?: number
|
||||
customer?: any
|
||||
vehicle_name?: string
|
||||
vehicle_id?: number
|
||||
vehicle?: any
|
||||
department_name?: string
|
||||
department_id?: number
|
||||
payment_terms_id?: number
|
||||
payment_mode_id?: number
|
||||
amount?: number | string | null
|
||||
received_payment?: number | string | null
|
||||
discount?: string
|
||||
has_insurance?: number | boolean
|
||||
insurer_id?: number | null
|
||||
insurer?: any
|
||||
kms_in?: number | null
|
||||
invoice_to_id?: number | null
|
||||
billing_address_id?: number | null
|
||||
delivery_address_id?: number | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type InvoiceGeneralInfoProps = {
|
||||
invoice: InvoiceData
|
||||
}
|
||||
import { useInvoice } from "./invoice-context"
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
@ -83,7 +49,7 @@ function InfoItem({
|
||||
)
|
||||
}
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
const statusVariantMap: Record<string, "secondary" | "default" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
open: "default",
|
||||
paid: "default",
|
||||
@ -91,56 +57,120 @@ const statusColorMap: Record<string, string> = {
|
||||
void: "outline",
|
||||
}
|
||||
|
||||
export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
function getDueInfo(dueDateStr?: string, status?: string) {
|
||||
if (!dueDateStr) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueDateStr)
|
||||
const diffMs = due.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
const isPaid = status === "paid" || status === "void"
|
||||
if (isPaid) return { label: formatDate(dueDateStr), variant: "neutral" as const }
|
||||
if (diffDays < 0) return { label: `${Math.abs(diffDays)} days overdue`, variant: "overdue" as const }
|
||||
if (diffDays === 0) return { label: "Due today", variant: "today" as const }
|
||||
if (diffDays <= 7) return { label: `Due in ${diffDays} day${diffDays === 1 ? "" : "s"}`, variant: "soon" as const }
|
||||
return { label: formatDate(dueDateStr), variant: "neutral" as const }
|
||||
}
|
||||
|
||||
export function InvoiceGeneralInfo() {
|
||||
const _invoice = useInvoice()
|
||||
if (!_invoice) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoice = _invoice as any
|
||||
|
||||
const customer = invoice.customer || {}
|
||||
const vehicle = invoice.vehicle || {}
|
||||
const insurer = invoice.insurer || {}
|
||||
const department = invoice.department || null
|
||||
|
||||
const total = parseFloat(String(invoice.total ?? 0)) || 0
|
||||
const paid = parseFloat(String(invoice.payments_recieved ?? invoice.received_payment ?? 0)) || 0
|
||||
const balanceDue = parseFloat(String(invoice.balance_due ?? 0)) || 0
|
||||
|
||||
const dueInfo = getDueInfo(invoice.due_date as string | undefined, invoice.status as string | undefined)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{/* Invoice Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{invoice.subject && (
|
||||
<Badge variant="secondary">{invoice.subject}</Badge>
|
||||
)}
|
||||
{invoice.status && (
|
||||
<Badge variant={statusColorMap[invoice.status] as any ?? "outline"}>
|
||||
{formatEnum(invoice.status)}
|
||||
|
||||
{/* ── Summary Hero ── */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Status */}
|
||||
<Card className="flex flex-col gap-1 p-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Status</span>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{invoice.status === "paid" && <CheckCircle2 className="size-4 text-green-500" />}
|
||||
{invoice.status === "overdue" && <AlertTriangle className="size-4 text-destructive" />}
|
||||
{(invoice.status === "draft" || invoice.status === "open") && <TimerIcon className="size-4 text-muted-foreground" />}
|
||||
<Badge variant={statusVariantMap[String(invoice.status ?? "")] ?? "outline"} className="text-sm px-2 py-0.5">
|
||||
{formatEnum(String(invoice.status ?? ""))}
|
||||
</Badge>
|
||||
</div>
|
||||
{invoice.invoice_number && (
|
||||
<span className="mt-1 text-xs text-muted-foreground">{invoice.invoice_number}</span>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Invoice Number"
|
||||
value={invoice.invoice_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Invoice Date"
|
||||
value={formatDate(invoice.invoice_date)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Calendar}
|
||||
label="Due Date"
|
||||
value={formatDate(invoice.due_date)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Customer & Vehicle Information */}
|
||||
{/* Due Date */}
|
||||
<Card className={cn(
|
||||
"flex flex-col gap-1 p-4",
|
||||
dueInfo?.variant === "overdue" && "border-destructive/60 bg-destructive/5",
|
||||
dueInfo?.variant === "today" && "border-orange-400/60 bg-orange-50 dark:bg-orange-950/20",
|
||||
dueInfo?.variant === "soon" && "border-yellow-400/60 bg-yellow-50 dark:bg-yellow-950/20",
|
||||
)}>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Due Date</span>
|
||||
<span className={cn(
|
||||
"mt-1 text-lg font-semibold",
|
||||
dueInfo?.variant === "overdue" && "text-destructive",
|
||||
dueInfo?.variant === "today" && "text-orange-600",
|
||||
dueInfo?.variant === "soon" && "text-yellow-700 dark:text-yellow-500",
|
||||
)}>
|
||||
{formatDate(invoice.due_date) || "—"}
|
||||
</span>
|
||||
{dueInfo && dueInfo.variant !== "neutral" && (
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
dueInfo.variant === "overdue" && "text-destructive",
|
||||
dueInfo.variant === "today" && "text-orange-600",
|
||||
dueInfo.variant === "soon" && "text-yellow-700 dark:text-yellow-500",
|
||||
)}>
|
||||
{dueInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Total Amount */}
|
||||
<Card className="flex flex-col gap-1 p-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Total Amount</span>
|
||||
<span className="mt-1 text-lg font-semibold">{formatCurrency(total)}</span>
|
||||
{paid > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{formatCurrency(paid)} received</span>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Balance Due */}
|
||||
<Card className={cn(
|
||||
"flex flex-col gap-1 p-4",
|
||||
balanceDue > 0 && invoice.status !== "paid" && "border-primary/40 bg-primary/5",
|
||||
balanceDue <= 0 && "border-green-500/40 bg-green-50 dark:bg-green-950/20",
|
||||
)}>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Balance Due</span>
|
||||
<span className={cn(
|
||||
"mt-1 text-lg font-bold",
|
||||
balanceDue > 0 && invoice.status !== "paid" && "text-primary",
|
||||
balanceDue <= 0 && "text-green-600",
|
||||
)}>
|
||||
{formatCurrency(balanceDue)}
|
||||
</span>
|
||||
{balanceDue <= 0 && (
|
||||
<span className="text-xs font-medium text-green-600">Fully paid</span>
|
||||
)}
|
||||
{invoice.discount && invoice.discount !== "no" && (
|
||||
<span className="text-xs text-muted-foreground">Discount: {formatEnum(invoice.discount)}</span>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Customer & Vehicle ── */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Customer Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@ -155,21 +185,9 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
label="Customer Name"
|
||||
value={customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : invoice.customer_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={customer.email}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Phone"
|
||||
value={customer.phone}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Phone}
|
||||
label="Alternate Phone"
|
||||
value={customer.alternate_phone}
|
||||
/>
|
||||
<InfoItem icon={Mail} label="Email" value={customer.email} />
|
||||
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
|
||||
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
|
||||
</div>
|
||||
{customer.address_line_1 && (
|
||||
<>
|
||||
@ -180,8 +198,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
{customer.address_line_1}
|
||||
{customer.address_line_2 ? `, ${customer.address_line_2}` : ""}
|
||||
<br />
|
||||
{customer.city ? `${customer.city}` : ""}
|
||||
{customer.zip_code ? `, ${customer.zip_code}` : ""}
|
||||
{customer.city ?? ""}{customer.zip_code ? `, ${customer.zip_code}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@ -189,7 +206,6 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vehicle Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@ -204,107 +220,49 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
label="Vehicle"
|
||||
value={vehicle.make && vehicle.model ? `${vehicle.make} ${vehicle.model}` : invoice.vehicle_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="License Plate"
|
||||
value={vehicle.license_plate}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="VIN"
|
||||
value={vehicle.vin_number}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Engine Number"
|
||||
value={vehicle.engine_number}
|
||||
/>
|
||||
<InfoItem icon={Hash} label="License Plate" value={vehicle.license_plate} />
|
||||
<InfoItem icon={Hash} label="VIN" value={vehicle.vin_number} />
|
||||
<InfoItem icon={Hash} label="Engine Number" value={vehicle.engine_number} />
|
||||
</div>
|
||||
{vehicle.mileage && (
|
||||
<>
|
||||
<Separator />
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="Mileage"
|
||||
value={formatNumber(vehicle.mileage)}
|
||||
/>
|
||||
<InfoItem icon={Clock} label="Mileage" value={formatNumber(vehicle.mileage)} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payment & Insurance Information */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Payment Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="size-4" />
|
||||
Payment Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Amount"
|
||||
value={invoice.amount ? formatCurrency(invoice.amount) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={CircleDollarSign}
|
||||
label="Received Payment"
|
||||
value={invoice.received_payment ? formatCurrency(invoice.received_payment) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Discount"
|
||||
value={invoice.discount}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Insurance & Additional Info */}
|
||||
{/* ── Invoice Meta ── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" />
|
||||
Additional Information
|
||||
Invoice Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={invoice.department_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Hash}
|
||||
label="Has Insurance"
|
||||
value={invoice.has_insurance ? "Yes" : "No"}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<InfoItem icon={Hash} label="Invoice Number" value={invoice.invoice_number} />
|
||||
<InfoItem icon={Calendar} label="Invoice Date" value={formatDate(invoice.invoice_date)} />
|
||||
<InfoItem icon={Calendar} label="Due Date" value={formatDate(invoice.due_date)} />
|
||||
<InfoItem icon={Building2} label="Department" value={department?.name} />
|
||||
<InfoItem icon={Hash} label="Has Insurance" value={invoice.has_insurance ? "Yes" : "No"} />
|
||||
{invoice.has_insurance && insurer.id && (
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Insurer"
|
||||
value={insurer.name}
|
||||
/>
|
||||
)}
|
||||
{invoice.kms_in && (
|
||||
<InfoItem
|
||||
icon={Clock}
|
||||
label="KMs In"
|
||||
value={formatNumber(invoice.kms_in)}
|
||||
/>
|
||||
<InfoItem icon={Users} label="Insurer" value={insurer.name} />
|
||||
)}
|
||||
{invoice.kms_in ? (
|
||||
<InfoItem icon={Clock} label="KMs In" value={formatNumber(invoice.kms_in)} />
|
||||
) : null}
|
||||
{invoice.invoice_title ? (
|
||||
<InfoItem icon={FileText} label="Invoice Title" value={invoice.invoice_title} />
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes & Terms */}
|
||||
{/* ── Notes & Terms ── */}
|
||||
{(invoice.notes || invoice.terms_and_conditions) && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{invoice.notes && (
|
||||
@ -313,7 +271,7 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
<CardTitle className="text-base">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.notes}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -323,13 +281,12 @@ export function InvoiceGeneralInfo({ invoice }: InvoiceGeneralInfoProps) {
|
||||
<CardTitle className="text-base">Terms & Conditions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{invoice.terms_and_conditions}</p>
|
||||
<p className="whitespace-pre-wrap text-sm">{invoice.terms_and_conditions}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
61
apps/dashboard/modules/invoices/invoice-payment-button.tsx
Normal file
61
apps/dashboard/modules/invoices/invoice-payment-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
apps/dashboard/modules/invoices/invoice-payments-section.tsx
Normal file
161
apps/dashboard/modules/invoices/invoice-payments-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
150
apps/dashboard/modules/invoices/invoice-sequence-form.tsx
Normal file
150
apps/dashboard/modules/invoices/invoice-sequence-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/dashboard/modules/invoices/invoice-status-badge.tsx
Normal file
117
apps/dashboard/modules/invoices/invoice-status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
apps/dashboard/modules/invoices/invoice-totals-summary.tsx
Normal file
102
apps/dashboard/modules/invoices/invoice-totals-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -9,6 +9,7 @@ const invoicePartItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -17,6 +18,7 @@ const invoiceServiceItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -25,6 +27,7 @@ const invoiceExpenseItemSchema = z.object({
|
||||
title: z.string(),
|
||||
quantity: z.number().min(1),
|
||||
rate: z.number().min(0),
|
||||
discount_amount: z.number().min(0).optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -52,6 +55,8 @@ const invoiceFormSchema = z.object({
|
||||
kms_in: z.coerce.number().optional(),
|
||||
has_insurance: z.boolean().default(false),
|
||||
discount: z.string().optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
tax: relationFieldSchema,
|
||||
deposit_to: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
terms_and_conditions: z.string().optional(),
|
||||
|
||||
@ -1,17 +1,26 @@
|
||||
|
||||
"use client"
|
||||
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 { Button } from '@/shared/components/ui/button'
|
||||
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 { 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 }) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter();
|
||||
const { print, isPrinting } = useDocumentPrint()
|
||||
const jobCard = useJobCard()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${id}/edit`)
|
||||
@ -21,6 +30,35 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
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 confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
@ -57,6 +95,15 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
{jobCard?.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleConvertToInvoice} disabled={isConverting}>
|
||||
<FileText className="size-4" />
|
||||
{isConverting ? "Converting..." : "Convert to Invoice"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
280
apps/dashboard/modules/job-cards/job-card-expense-item-form.tsx
Normal file
280
apps/dashboard/modules/job-cards/job-card-expense-item-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -26,8 +26,9 @@ import {
|
||||
type JobCardFormValues,
|
||||
FUEL_LEVEL_OPTIONS,
|
||||
JOB_CARD_STATUS_OPTIONS,
|
||||
DISCOUNT_TYPE_OPTIONS,
|
||||
} 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 { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
|
||||
@ -55,6 +56,7 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
sales_person: null,
|
||||
insurance_type: null,
|
||||
insurer: null,
|
||||
tax: null,
|
||||
order_number: "",
|
||||
estimate_number: "",
|
||||
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),
|
||||
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),
|
||||
tax: toRelation(d.tax_id, d.tax ? `${d.tax.title} (${d.tax.rate}%)` : undefined),
|
||||
order_number: d.order_number || "",
|
||||
estimate_number: d.estimate_number || "",
|
||||
status: d.status || "draft",
|
||||
@ -148,6 +151,7 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
sales_person_id: toId(values.sales_person),
|
||||
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
|
||||
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
|
||||
tax_id: toId(values.tax) ?? undefined,
|
||||
estimate_number: values.estimate_number || undefined,
|
||||
order_number: values.order_number || undefined,
|
||||
status: values.status || undefined,
|
||||
@ -347,6 +351,24 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
placeholder="Select status"
|
||||
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" />
|
||||
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
|
||||
<RhfCheckboxField
|
||||
|
||||
@ -29,10 +29,9 @@ import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
|
||||
import JobCardPaymentsReceived from "./job-card-payments-received"
|
||||
import { formatDate } from "@/shared/utils/formatters"
|
||||
import type { JobCardShowData } from "@garage/api"
|
||||
|
||||
|
||||
|
||||
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
|
||||
type JobCard = JobCardShowData
|
||||
|
||||
|
||||
function InfoItem({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
@ -18,26 +18,27 @@ 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 ──
|
||||
// ── Schema ──
|
||||
|
||||
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
|
||||
|
||||
const jobCardPartFormSchema = z.object({
|
||||
part: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable(),
|
||||
department: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
part: 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"),
|
||||
tax: z.string().optional(),
|
||||
discount_amount: z.coerce.number().min(0).optional(),
|
||||
chart_of_account: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
|
||||
|
||||
// ── Props ──
|
||||
// ── Props ──
|
||||
|
||||
export type JobCardPartFormProps = {
|
||||
jobCardId: string
|
||||
@ -50,9 +51,11 @@ export type JobCardPartFormProps = {
|
||||
const DEFAULT_VALUES: JobCardPartFormValues = {
|
||||
part: null,
|
||||
department: null,
|
||||
tax: null,
|
||||
quantity: 1,
|
||||
rate: 0,
|
||||
tax: "",
|
||||
discount_amount: undefined,
|
||||
chart_of_account: "",
|
||||
description: "",
|
||||
}
|
||||
|
||||
@ -67,14 +70,18 @@ function mapToFormValues(data: unknown): JobCardPartFormValues {
|
||||
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,
|
||||
tax: d.tax ?? "",
|
||||
discount_amount: d.discount_amount != null ? Number(d.discount_amount) : undefined,
|
||||
chart_of_account: d.chart_of_account ?? "",
|
||||
description: d.description ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
// ── Component ──
|
||||
|
||||
export function JobCardPartForm({
|
||||
jobCardId,
|
||||
@ -84,7 +91,9 @@ export function JobCardPartForm({
|
||||
onCancel,
|
||||
}: JobCardPartFormProps) {
|
||||
const api = useAuthApi()
|
||||
const jobCard = useJobCard()
|
||||
const isEditing = !!jobCardPartId
|
||||
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
|
||||
|
||||
const form = useForm<JobCardPartFormValues>({
|
||||
resolver: zodResolver(jobCardPartFormSchema) as any,
|
||||
@ -106,6 +115,7 @@ export function JobCardPartForm({
|
||||
job_card_part_id: jobCardPartId,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
{
|
||||
@ -119,9 +129,11 @@ export function JobCardPartForm({
|
||||
api.jobCards.addPart(jobCardId, {
|
||||
part_id: values.part ? Number(values.part.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,
|
||||
tax: values.tax || undefined,
|
||||
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
|
||||
chart_of_account: values.chart_of_account || undefined,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
{
|
||||
@ -186,13 +198,23 @@ export function JobCardPartForm({
|
||||
/>
|
||||
</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={["departments"]}
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
@ -202,12 +224,26 @@ export function JobCardPartForm({
|
||||
createLabel="Department"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField
|
||||
<RhfAsyncSelectField
|
||||
name="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>
|
||||
|
||||
<RhfTextField
|
||||
name="chart_of_account"
|
||||
label="Chart of Account"
|
||||
placeholder="e.g. COA-300"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RhfTextareaField
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@ -19,28 +19,24 @@ import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useForm } from "react-hook-form"
|
||||
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 ──
|
||||
|
||||
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
|
||||
|
||||
const jobCardServiceFormSchema = z.object({
|
||||
service: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable(),
|
||||
department: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
service: relationFieldSchema,
|
||||
department: z.object({ value: z.string(), label: z.string() }),
|
||||
tax: relationFieldSchema.optional(),
|
||||
rate_type: z.string().optional(),
|
||||
labor_rate: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
labor_rate: relationFieldSchema.optional(),
|
||||
quantity: z.coerce.number().min(1, "Quantity is required"),
|
||||
rate: z.coerce.number().min(0, "Rate is required"),
|
||||
working_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(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
@ -59,14 +55,15 @@ export type JobCardServiceFormProps = {
|
||||
|
||||
const DEFAULT_VALUES: JobCardServiceFormValues = {
|
||||
service: null,
|
||||
department: null,
|
||||
department: undefined as any,
|
||||
tax: null,
|
||||
rate_type: "flat_rate",
|
||||
labor_rate: null,
|
||||
quantity: 1,
|
||||
rate: 0,
|
||||
working_hours: 0,
|
||||
labor_hours: 0,
|
||||
tax: "",
|
||||
discount_amount: undefined,
|
||||
chart_of_account: "",
|
||||
description: "",
|
||||
}
|
||||
@ -81,6 +78,9 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
|
||||
: null,
|
||||
department: d.department
|
||||
? { 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,
|
||||
rate_type: d.rate_type ?? "flat_rate",
|
||||
labor_rate: d.labor_rate
|
||||
@ -90,7 +90,7 @@ function mapToFormValues(data: unknown): JobCardServiceFormValues {
|
||||
rate: d.rate != null ? Number(d.rate) : 0,
|
||||
working_hours: d.working_hours != null ? Number(d.working_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 ?? "",
|
||||
description: d.description ?? "",
|
||||
}
|
||||
@ -111,7 +111,9 @@ export function JobCardServiceForm({
|
||||
onCancel,
|
||||
}: JobCardServiceFormProps) {
|
||||
const api = useAuthApi()
|
||||
const jobCard = useJobCard()
|
||||
const isEditing = !!jobCardServiceId
|
||||
const isLineItemDiscount = (jobCard as any)?.discount_type === "line_item_level"
|
||||
|
||||
const form = useForm<JobCardServiceFormValues>({
|
||||
resolver: zodResolver(jobCardServiceFormSchema) as any,
|
||||
@ -120,6 +122,9 @@ export function JobCardServiceForm({
|
||||
: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const rateType = form.watch("rate_type")
|
||||
const isHourly = rateType === "hourly"
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
@ -133,6 +138,7 @@ export function JobCardServiceForm({
|
||||
job_card_service_id: jobCardServiceId,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
discount_amount: isLineItemDiscount ? (values.discount_amount ?? 0) : undefined,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
{
|
||||
@ -146,13 +152,14 @@ export function JobCardServiceForm({
|
||||
api.jobCards.addService(jobCardId, {
|
||||
service_id: values.service ? Number(values.service.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,
|
||||
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
working_hours: values.working_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,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
@ -218,6 +225,15 @@ export function JobCardServiceForm({
|
||||
/>
|
||||
</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">
|
||||
@ -241,6 +257,7 @@ export function JobCardServiceForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isHourly && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="working_hours"
|
||||
@ -255,13 +272,15 @@ export function JobCardServiceForm({
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={["departments"]}
|
||||
required
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
@ -271,10 +290,17 @@ export function JobCardServiceForm({
|
||||
createLabel="Department"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField
|
||||
<RhfAsyncSelectField
|
||||
name="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>
|
||||
|
||||
@ -302,9 +328,21 @@ export function JobCardServiceForm({
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? 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>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ const jobCardFormSchema = z.object({
|
||||
sales_person: relationFieldSchema,
|
||||
insurance_type: relationFieldSchema,
|
||||
insurer: relationFieldSchema,
|
||||
tax: relationFieldSchema,
|
||||
|
||||
// ── Numbers & identifiers ──
|
||||
order_number: z.string().optional(),
|
||||
|
||||
@ -22,6 +22,7 @@ type PartItem = {
|
||||
title: string
|
||||
quantity: number
|
||||
rate: number
|
||||
discount_amount?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
@ -34,6 +35,7 @@ export type PartsSelectorFieldProps<
|
||||
name: TName & (TValues[TName] extends PartsItemsFieldConstraint ? TName : never)
|
||||
label?: string
|
||||
triggerLabel?: string
|
||||
showDiscount?: boolean
|
||||
}
|
||||
|
||||
export function PartsSelectorField<
|
||||
@ -43,6 +45,7 @@ export function PartsSelectorField<
|
||||
name,
|
||||
label = "Parts",
|
||||
triggerLabel = "Add Parts",
|
||||
showDiscount = false,
|
||||
}: PartsSelectorFieldProps<TValues, TName>) {
|
||||
return (
|
||||
<RhfResourceField<TValues, TName, PartsClient>
|
||||
@ -81,6 +84,7 @@ export function PartsSelectorField<
|
||||
<TableHead>Part</TableHead>
|
||||
<TableHead className="w-24">Qty</TableHead>
|
||||
<TableHead className="w-28">Rate</TableHead>
|
||||
{showDiscount && <TableHead className="w-28">Discount</TableHead>}
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
@ -112,6 +116,20 @@ export function PartsSelectorField<
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
value={item.description ?? ""}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@ -11,12 +12,14 @@ import {
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfAutoGenerateField,
|
||||
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 { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { toRelation, toId, getTodayDate } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
paymentMadeFormSchema,
|
||||
@ -25,10 +28,10 @@ import {
|
||||
import {
|
||||
PAYMENT_MADE_ROUTES,
|
||||
PAYMENT_MODE_ROUTES,
|
||||
VENDOR_ROUTES,
|
||||
EMPLOYEE_ROUTES,
|
||||
PaymentFor,
|
||||
} from "@garage/api"
|
||||
import { RhfVendorSelectField } from "@/modules/vendors/rhf-vendor-select-field"
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
@ -43,54 +46,70 @@ export type PaymentMadeFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
billId?: string | number | null
|
||||
expenseId?: string | number | null
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
employee: null,
|
||||
payment_mode: null,
|
||||
payment_for: "",
|
||||
payment_made: "",
|
||||
payment_for: "bill",
|
||||
amount: "",
|
||||
payment_number: "",
|
||||
payment_reference: "",
|
||||
payment_date: "",
|
||||
payment_date: getTodayDate(),
|
||||
paid_through: "",
|
||||
notes: "",
|
||||
details: []
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): PaymentMadeFormValues {
|
||||
function mapToFormValues(data: unknown): typeof DEFAULT_VALUES {
|
||||
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 {
|
||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
||||
employee: toRelation(d.employee_id, d.employee_name),
|
||||
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name),
|
||||
vendor: toRelation(d.vendor_id, d.vendor?.company_name ?? d.vendor?.name ?? d.vendor_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(paymentModeId, paymentModeLabel),
|
||||
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_reference: d.payment_reference || "",
|
||||
payment_date: d.payment_date || "",
|
||||
paid_through: d.paid_through || "",
|
||||
notes: d.notes || "",
|
||||
paid_through: d.paid_through || "-",
|
||||
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 {
|
||||
vendor_id: toId(values.vendor),
|
||||
employee_id: toId(values.employee) || undefined,
|
||||
payment_mode_id: toId(values.payment_mode),
|
||||
payment_for: values.payment_for,
|
||||
payment_made: values.payment_made,
|
||||
amount: values.amount ? Number(values.amount) : 0,
|
||||
payment_number: values.payment_number || undefined,
|
||||
payment_reference: values.payment_reference || undefined,
|
||||
payment_date: values.payment_date,
|
||||
paid_through: values.paid_through || 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 ──
|
||||
|
||||
export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentMadeFormProps) {
|
||||
export function PaymentMadeForm({ resourceId, initialData, onSuccess, billId, expenseId }: PaymentMadeFormProps) {
|
||||
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>({
|
||||
schema: paymentMadeFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
initialData: resolvedInitialData,
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PaymentMadeFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const payload = mapFormToPayload(values, billId, expenseId)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.paymentMades.update(resourceId, payload as any)
|
||||
: api.paymentMades.create(payload as any)) as Promise<any>
|
||||
@ -161,14 +192,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
<RhfVendorSelectField
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
listFn={() => api.vendors.list()}
|
||||
mapOption={mapVendorOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="employee"
|
||||
@ -190,7 +216,7 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="payment_made"
|
||||
name="amount"
|
||||
label="Amount"
|
||||
placeholder="0.00"
|
||||
type="number"
|
||||
@ -199,7 +225,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
<RhfAutoGenerateField
|
||||
table="payments"
|
||||
autoFetch
|
||||
name="payment_number"
|
||||
label="Payment Number"
|
||||
placeholder="PAY-001"
|
||||
@ -212,10 +240,9 @@ export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentM
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
<RhfDateField
|
||||
name="payment_date"
|
||||
label="Payment Date"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
|
||||
@ -11,8 +11,8 @@ const paymentMadeFormSchema = z.object({
|
||||
payment_mode: relationFieldSchema,
|
||||
|
||||
// ── Payment info ──
|
||||
amount: z.string().min(1, "Amount 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_reference: z.string().optional(),
|
||||
payment_date: z.string().min(1, "Payment date is required"),
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
RhfAutoGenerateField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@ -31,6 +33,9 @@ export type PaymentReceivedFormProps = {
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
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 ──
|
||||
@ -41,7 +46,7 @@ const DEFAULT_VALUES: PaymentReceivedFormValues = {
|
||||
customer: null,
|
||||
amount_received: "",
|
||||
payment_number: "",
|
||||
payment_date: "",
|
||||
payment_date: new Date().toISOString().split("T")[0],
|
||||
note: "",
|
||||
}
|
||||
|
||||
@ -61,7 +66,7 @@ function mapToFormValues(data: unknown): PaymentReceivedFormValues {
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: PaymentReceivedFormValues) {
|
||||
function mapFormToPayload(values: PaymentReceivedFormValues, invoiceId?: string | null) {
|
||||
return {
|
||||
job_card_id: toId(values.job_card),
|
||||
payment_mode_id: toId(values.payment_mode),
|
||||
@ -70,6 +75,7 @@ function mapFormToPayload(values: PaymentReceivedFormValues) {
|
||||
payment_number: values.payment_number || undefined,
|
||||
payment_date: values.payment_date,
|
||||
note: values.note || undefined,
|
||||
...(invoiceId ? { invoice_id: Number(invoiceId) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,18 +90,29 @@ const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) =
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard }: PaymentReceivedFormProps) {
|
||||
export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaultJobCard, invoiceId, invoiceCustomer, invoiceAmount }: PaymentReceivedFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const resolvedInitialData = useMemo(() => {
|
||||
if (!resourceId && defaultJobCard?.id != null) {
|
||||
return {
|
||||
...(initialData as any),
|
||||
job_card: toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined),
|
||||
const base: any = { ...(initialData as any) }
|
||||
if (!resourceId) {
|
||||
if (defaultJobCard?.id != null) {
|
||||
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
|
||||
}, [resourceId, defaultJobCard, initialData])
|
||||
return Object.keys(base).length ? base : initialData
|
||||
}, [resourceId, defaultJobCard, initialData, invoiceCustomer, invoiceAmount])
|
||||
|
||||
const { form, isEditing } = useResourceForm<PaymentReceivedFormValues, any>({
|
||||
schema: paymentReceivedFormSchema,
|
||||
@ -107,7 +124,7 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PaymentReceivedFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const payload = mapFormToPayload(values, invoiceId)
|
||||
const promise = isEditing && resourceId
|
||||
? api.paymentReceived.update(resourceId, payload as any)
|
||||
: api.paymentReceived.create(payload as any)
|
||||
@ -186,16 +203,16 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
<RhfAutoGenerateField
|
||||
autoFetch
|
||||
table="payments"
|
||||
name="payment_number"
|
||||
label="Payment Number"
|
||||
placeholder="PAY-001"
|
||||
/>
|
||||
<RhfTextField
|
||||
<RhfDateField
|
||||
name="payment_date"
|
||||
label="Payment Date"
|
||||
type="date"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { BillForm } from "@/modules/bills/bill-form"
|
||||
import { toRelation } from "@/shared/lib/utils"
|
||||
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
|
||||
@ -26,9 +27,9 @@ function mapPOToBillInitialData(po: Record<string, any>) {
|
||||
notes: po.notes ?? "",
|
||||
|
||||
// Relation fields — must be { value, label } objects for RhfAsyncSelectField
|
||||
vendor: toRelation(po.vendor_id, po.vendor_name),
|
||||
department: toRelation(po.department_id, po.department_name),
|
||||
job_card: toRelation(po.job_card_id, po.job_card_name ?? po.job_card_number),
|
||||
vendor: toRelation(po.vendor_id, getFullName(po.vendor)),
|
||||
department: toRelation(po.department_id, po.department.name),
|
||||
job_card: toRelation(po.job_card_id, po.job_card.title ),
|
||||
// Link bill back to the source PO
|
||||
purchase_order: toRelation(po.id, po.order_number ?? po.title),
|
||||
|
||||
|
||||
@ -11,8 +11,10 @@ import {
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
RhfAutoGenerateField,
|
||||
} from "@/shared/components/form"
|
||||
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||
import { VendorForm } from "@/modules/vendors/vendor-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
@ -38,7 +40,7 @@ export type PurchaseOrderFormProps = {
|
||||
|
||||
const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
||||
vendor: null,
|
||||
order_number: "" ,
|
||||
order_number: "",
|
||||
job_card: null,
|
||||
department: null,
|
||||
title: "",
|
||||
@ -98,6 +100,13 @@ const mapLookupOption = (item: any) => ({
|
||||
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 }
|
||||
|
||||
// ── Component ──
|
||||
@ -148,7 +157,7 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
|
||||
<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="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="delivery_date" label="Delivery Date" />
|
||||
</div>
|
||||
@ -160,7 +169,16 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
||||
placeholder="Select vendor"
|
||||
queryKey={[VENDOR_ROUTES.INDEX]}
|
||||
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}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
|
||||
@ -22,6 +22,7 @@ type ServiceLineItem = {
|
||||
title: string
|
||||
quantity: number
|
||||
rate: number
|
||||
discount_amount?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
@ -34,6 +35,7 @@ export type ServicesSelectorFieldProps<
|
||||
name: TName & (TValues[TName] extends ServiceItemsFieldConstraint ? TName : never)
|
||||
label?: string
|
||||
triggerLabel?: string
|
||||
showDiscount?: boolean
|
||||
}
|
||||
|
||||
export function ServicesSelectorField<
|
||||
@ -43,6 +45,7 @@ export function ServicesSelectorField<
|
||||
name,
|
||||
label = "Services",
|
||||
triggerLabel = "Add Services",
|
||||
showDiscount = false,
|
||||
}: ServicesSelectorFieldProps<TValues, TName>) {
|
||||
return (
|
||||
<RhfResourceField<TValues, TName, ServicesClient>
|
||||
@ -79,6 +82,7 @@ export function ServicesSelectorField<
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead className="w-24">Qty</TableHead>
|
||||
<TableHead className="w-28">Rate</TableHead>
|
||||
{showDiscount && <TableHead className="w-28">Discount</TableHead>}
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
@ -110,6 +114,20 @@ export function ServicesSelectorField<
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
value={item.description ?? ""}
|
||||
|
||||
@ -148,9 +148,14 @@ export function SettingsForm() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{/* General Info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* Main Content - 8/12 */}
|
||||
<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
|
||||
name="name"
|
||||
label="Workshop Name"
|
||||
@ -167,7 +172,7 @@ export function SettingsForm() {
|
||||
/>
|
||||
</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
|
||||
name="phone"
|
||||
label="Phone"
|
||||
@ -184,7 +189,7 @@ export function SettingsForm() {
|
||||
/>
|
||||
</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
|
||||
name="website"
|
||||
label="Website"
|
||||
@ -198,38 +203,28 @@ export function SettingsForm() {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Address */}
|
||||
{/* Address Section */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="mb-4 text-base font-semibold">Address</h3>
|
||||
<RhfTextField
|
||||
name="first_address_line"
|
||||
label="Address Line 1"
|
||||
placeholder="Street 10"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RhfTextField
|
||||
name="second_address_line"
|
||||
label="Address Line 2"
|
||||
placeholder="Near Central Plaza"
|
||||
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
|
||||
name="country"
|
||||
label="Country"
|
||||
@ -250,7 +245,7 @@ export function SettingsForm() {
|
||||
/>
|
||||
</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
|
||||
name="city"
|
||||
label="City"
|
||||
@ -264,15 +259,45 @@ export function SettingsForm() {
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</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
|
||||
name="latitude"
|
||||
label="Latitude"
|
||||
placeholder="25.2048"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RhfTextField
|
||||
name="longitude"
|
||||
label="Longitude"
|
||||
@ -281,39 +306,62 @@ export function SettingsForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Other */}
|
||||
<RhfTextareaField
|
||||
name="bank_details"
|
||||
label="Bank Details"
|
||||
placeholder="Bank name, account number, IBAN..."
|
||||
<div className="mt-4">
|
||||
<RhfTextField
|
||||
name="time_zone"
|
||||
label="Time Zone"
|
||||
placeholder="Asia/Dubai"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="About the workshop..."
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RhfSelectField
|
||||
name="first_day_of_work"
|
||||
label="First Day of Work"
|
||||
placeholder="Select day"
|
||||
options={FIRST_DAY_OPTIONS}
|
||||
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
|
||||
name="security"
|
||||
label="Security Policy"
|
||||
placeholder="Security policy text..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RhfTextareaField
|
||||
name="privacy_policy"
|
||||
label="Privacy Policy"
|
||||
placeholder="Privacy policy text..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || isLoading}>
|
||||
<Save />
|
||||
{/* Action Button */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || isLoading}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
<Save className="me-2" />
|
||||
{isPending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
|
||||
237
apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx
vendored
Normal file
237
apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -25,17 +25,18 @@ import { VENDOR_ROUTES } from "@garage/api"
|
||||
export type VendorFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
onSuccess?: (data?: unknown) => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: VendorFormValues = {
|
||||
salutation:"Mr.",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
company_name: "",
|
||||
email: "",
|
||||
}
|
||||
} as any
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
@ -85,9 +86,9 @@ export function VendorForm({ resourceId, initialData, onSuccess }: VendorFormPro
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
onSuccess?.(data)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
110
apps/dashboard/shared/components/document-totals-summary.tsx
Normal file
110
apps/dashboard/shared/components/document-totals-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -109,15 +109,14 @@ export function RhfResourceField<
|
||||
{triggerLabel ?? label}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{
|
||||
items.length > 0 &&
|
||||
<CardContent>
|
||||
{(items as any[]).length > 0
|
||||
? renderItems(items, helpers)
|
||||
: (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No items added yet.
|
||||
</p>
|
||||
)}
|
||||
&& renderItems(items, helpers)
|
||||
}
|
||||
</CardContent>
|
||||
}
|
||||
</Card>
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
<ResourceSelectorDialog<TClient>
|
||||
|
||||
@ -44,7 +44,11 @@ function ActionsCell<TData extends { id: string | number }>({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{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" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@ -52,7 +56,10 @@ function ActionsCell<TData extends { id: string | number }>({
|
||||
{options.onDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => options.onDelete!(row.original)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
options.onDelete!(row.original)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
|
||||
84
apps/dashboard/shared/hooks/use-document-totals.ts
Normal file
84
apps/dashboard/shared/hooks/use-document-totals.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -24,3 +24,8 @@ export function toId(relation: RelationFieldValue | undefined): number | undefin
|
||||
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
@ -1,6 +1,6 @@
|
||||
import { CrudClient } from "../infra/crud-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 = {
|
||||
INDEX: "/api/estimates",
|
||||
@ -12,6 +12,7 @@ export const ESTIMATE_ROUTES = {
|
||||
EXPENSE_ITEMS: "/api/estimate/{id}/expense-items",
|
||||
EXPENSE_ITEM_BY_ID: "/api/estimate/{id}/expense-items/{expense_item_id}",
|
||||
STORE_AUTHORISATION: "/api/estimates/{id}/store-authorisation",
|
||||
CONVERT_TO_JOB_CARD: "/api/estimates/{id}/convert-to-job-card",
|
||||
} as const satisfies Record<string, ApiPath>
|
||||
|
||||
export class EstimatesClient extends CrudClient<
|
||||
@ -22,13 +23,6 @@ export class EstimatesClient extends CrudClient<
|
||||
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 ──
|
||||
async listServices(estimateId: string) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,6 +66,10 @@ export class ExpensesClient extends CrudClient<
|
||||
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">) {
|
||||
return this.create(payload)
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ export { EstimatesClient, ESTIMATE_ROUTES } from "./estimates"
|
||||
export { QuickRemarksClient, QUICK_REMARK_ROUTES } from "./quick-remarks"
|
||||
export { QuickNotesClient, QUICK_NOTE_ROUTES } from "./quick-notes"
|
||||
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 { PaymentReceivedClient, PAYMENT_RECEIVED_ROUTES } from "./payment-received"
|
||||
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 { HolidayYearsClient, HOLIDAY_YEAR_ROUTES } from "./holiday-years"
|
||||
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 { BillsClient, BILL_ROUTES } from "./bills"
|
||||
export { ReasonsClient, REASON_ROUTES } from "./reasons"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CrudClient } from "../infra/crud-client"
|
||||
import { type ApiClientOptions } from "../infra/client"
|
||||
import type { ApiPath, ApiRequestBody } from "../infra/types"
|
||||
import type { ApiListQueryParams } from "../contracts/types"
|
||||
import type { ApiListQueryParams, ApiBaseResponse } from "../contracts/types"
|
||||
|
||||
export const INVOICE_ROUTES = {
|
||||
INDEX: "/api/invoices",
|
||||
@ -16,9 +16,28 @@ export const INVOICE_ROUTES = {
|
||||
LABEL_BY_ID: "/api/invoice-labels/{id}",
|
||||
} 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<
|
||||
typeof INVOICE_ROUTES.INDEX,
|
||||
typeof INVOICE_ROUTES.BY_ID
|
||||
typeof INVOICE_ROUTES.BY_ID,
|
||||
{
|
||||
showResponse: ApiBaseResponse<InvoiceShowData>
|
||||
}
|
||||
> {
|
||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||
super(baseUrl, defaultOptions, INVOICE_ROUTES.INDEX, INVOICE_ROUTES.BY_ID)
|
||||
|
||||
@ -30,6 +30,11 @@ export const JOB_CARD_ROUTES = {
|
||||
DELETE_SERVICE: "/api/job-cards/{id}/delete-service",
|
||||
ADD_SERVICE_ATTACHMENT: "/api/job-cards/{id}/add-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>
|
||||
|
||||
|
||||
@ -157,4 +162,24 @@ export class JobCardsClient extends CrudClient<
|
||||
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)
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,7 +109,7 @@ export const SellRatesTaxInclusive = ['Tax Inclusive', 'Tax Exclusive'] as const
|
||||
export type SellRatesTaxInclusive = (typeof SellRatesTaxInclusive)[number];
|
||||
// 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 const GiveDiscounts = ['no', 'line_item_level', 'transaction_level'] as const;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user