+ const handleCreateSuccess = (data?: any) => {
+ const item = data?.data ?? data
+ if (item?.id) {
+ field.onChange(buildCustomerOption(item))
+ }
+ queryClient.invalidateQueries({ queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"] })
+ setIsCreateOpen(false)
+ }
+
+ const combobox = (
+
{
@@ -200,6 +209,44 @@ export function RhfCustomerSelectField<
-
+ )
+
+ return (
+
+ {label && (
+
+
+ {label}
+ {required && *}
+
+
+
+ )}
+ {combobox}
+ {description && {description}}
+ {error?.message && {error.message}}
+
+
+
)
}
diff --git a/apps/dashboard/modules/estimates/estimate-form.tsx b/apps/dashboard/modules/estimates/estimate-form.tsx
index 547cc94..4f94f4e 100644
--- a/apps/dashboard/modules/estimates/estimate-form.tsx
+++ b/apps/dashboard/modules/estimates/estimate-form.tsx
@@ -4,14 +4,13 @@ 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 {
Rhform,
RhfTextField,
- RhfTextareaField,
RhfCheckboxField,
RhfAsyncSelectField,
- RhfAsyncMultiSelectField,
+ RhfDateField,
+ RhfAutoGenerateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@@ -23,9 +22,11 @@ import {
estimateFormSchema,
type EstimateFormValues,
} from "./estimate.schema"
-import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
+import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
+import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field"
+import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field"
// ── Props ──
@@ -46,7 +47,7 @@ const DEFAULT_VALUES: EstimateFormValues = {
estimate_number: "",
date: "",
has_insurance: false,
- remarks: "",
+ remarks: [],
labels: [],
}
@@ -61,12 +62,16 @@ function mapToFormValues(data: unknown): EstimateFormValues {
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
department: toRelation(d.department_id, d.department_name),
estimate_number: d.estimate_number || "",
- date: d.date || "",
+ date: d.date ? d.date.split("T")[0] : "",
has_insurance: d.has_insurance ?? false,
- remarks: Array.isArray(d.remarks) ? d.remarks.join("\n") : d.remarks || "",
- labels: Array.isArray(d.labels)
- ? d.labels.map((l: any) => ({ value: String(l.id), label: l.name }))
+ remarks: Array.isArray(d.remarks)
+ ? d.remarks.map((r: any) => (typeof r === "string" ? r : (r?.remark ?? ""))).filter(Boolean)
: [],
+ labels: (d.labels ?? []).map((l: any): LabelItem => ({
+ id: l.id,
+ title: l.title ?? l.name ?? "",
+ color_code: l.color_code ?? "",
+ })),
}
}
@@ -79,10 +84,8 @@ function mapFormToPayload(values: EstimateFormValues) {
estimate_number: values.estimate_number || undefined,
date: values.date || undefined,
has_insurance: values.has_insurance,
- remarks: values.remarks
- ? values.remarks.split("\n").filter(Boolean)
- : [],
- label_ids: values.labels.map((l) => Number(l.value)),
+ remarks: values.remarks?.filter(Boolean) ?? [],
+ label_ids: values.labels?.map((l) => l.id) ?? [],
}
}
@@ -140,20 +143,17 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
)}
-
-
+
+
-
-
-
-
+
-
+
- api.labels.list()}
- mapOption={mapLookupOption}
- {...STORE_OBJECT}
- />
+
+
-
+
-
+
)
}
diff --git a/apps/dashboard/modules/estimates/estimate.schema.ts b/apps/dashboard/modules/estimates/estimate.schema.ts
index b59b956..b6d6a47 100644
--- a/apps/dashboard/modules/estimates/estimate.schema.ts
+++ b/apps/dashboard/modules/estimates/estimate.schema.ts
@@ -17,12 +17,20 @@ const estimateFormSchema = z.object({
estimate_number: z.string().optional(),
date: z.string().optional(),
has_insurance: z.boolean().default(false),
- remarks: z.string().optional(),
- // ── Multi-select relations ──
+ // ── Remarks (array of strings) ──
+ remarks: z.array(z.string()).optional(),
+
+ // ── Labels ──
labels: z
- .array(z.object({ value: z.string(), label: z.string() }))
- .default([]),
+ .array(
+ z.object({
+ id: z.number(),
+ title: z.string(),
+ color_code: z.string(),
+ }),
+ )
+ .optional(),
})
type EstimateFormValues = z.infer
diff --git a/apps/dashboard/modules/expense-items/expense-items-columns.tsx b/apps/dashboard/modules/expense-items/expense-items-columns.tsx
new file mode 100644
index 0000000..86daeed
--- /dev/null
+++ b/apps/dashboard/modules/expense-items/expense-items-columns.tsx
@@ -0,0 +1,27 @@
+import { ColumnHeader } from "@/shared/data-view/table-view"
+import type { ColumnDef } from "@tanstack/react-table"
+
+/** Core expense-item columns shared between the expense items page and selector dialogs. */
+export const expenseItemColumns = {
+ name: {
+ accessorKey: "item_name",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const r = row.original as any
+ return {r.item_name || "—"}
+ },
+ },
+ purchasePrice: {
+ accessorKey: "purchase_price",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const val = (row.original as any).purchase_price
+ return val != null ? `$${Number(val).toFixed(2)}` : "—"
+ },
+ },
+ chartOfAccount: {
+ accessorKey: "purchase_chart_of_account",
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.original as any).purchase_chart_of_account || "—",
+ },
+} satisfies Record>
diff --git a/apps/dashboard/modules/expense-items/expense-items-selector-field.tsx b/apps/dashboard/modules/expense-items/expense-items-selector-field.tsx
new file mode 100644
index 0000000..aeea3bb
--- /dev/null
+++ b/apps/dashboard/modules/expense-items/expense-items-selector-field.tsx
@@ -0,0 +1,140 @@
+"use client"
+
+import type { FieldValues, FieldPath } from "react-hook-form"
+import { Trash2 } from "lucide-react"
+import { Button } from "@/shared/components/ui/button"
+import { Input } from "@/shared/components/ui/input"
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableHead,
+ TableRow,
+ TableCell,
+} from "@/shared/components/ui/table"
+import { RhfResourceField } from "@/shared/components/resource-selector"
+import { expenseItemColumns } from "./expense-items-columns"
+import { EXPENSE_ITEM_ROUTES } from "@garage/api"
+import type { ExpenseItemsClient } from "@garage/api"
+
+type ExpenseLineItem = {
+ expense_id: number
+ title: string
+ quantity: number
+ rate: number
+ description?: string
+}
+
+type ExpenseItemsFieldConstraint = ExpenseLineItem[] | undefined
+
+export type ExpenseItemsSelectorFieldProps<
+ TValues extends FieldValues,
+ TName extends FieldPath,
+> = {
+ name: TName & (TValues[TName] extends ExpenseItemsFieldConstraint ? TName : never)
+ label?: string
+ triggerLabel?: string
+}
+
+export function ExpenseItemsSelectorField<
+ TValues extends FieldValues,
+ TName extends FieldPath,
+>({
+ name,
+ label = "Expense Items",
+ triggerLabel = "Add Expense Items",
+}: ExpenseItemsSelectorFieldProps) {
+ return (
+
+ name={name}
+ label={label}
+ triggerLabel={triggerLabel}
+ itemKey="expense_id"
+ dialogProps={{
+ title: "Select Expense Items",
+ crudProps: {
+ routeKey: EXPENSE_ITEM_ROUTES.INDEX,
+ getClient: (api) => api.expenseItems,
+ columns: [
+ expenseItemColumns.name,
+ expenseItemColumns.purchasePrice,
+ expenseItemColumns.chartOfAccount,
+ ],
+ },
+ }}
+ mapSelected={(row) => {
+ const r = row as any
+ return {
+ expense_id: r.id,
+ title: r.item_name || "",
+ quantity: 1,
+ rate: Number(r.purchase_price) || 0,
+ description: "",
+ } as any
+ }}
+ renderItems={(items, { remove, update }) => (
+
+ )}
+ />
+ )
+}
diff --git a/apps/dashboard/modules/parts/part-form.tsx b/apps/dashboard/modules/parts/part-form.tsx
index 3f7869f..ed61758 100644
--- a/apps/dashboard/modules/parts/part-form.tsx
+++ b/apps/dashboard/modules/parts/part-form.tsx
@@ -44,6 +44,8 @@ const DEFAULT_VALUES: PartFormValues = {
description: "",
selling_price: undefined,
purchase_price: undefined,
+ opening_stock: undefined,
+
}
// ── Mapping helpers ──
@@ -68,6 +70,7 @@ function mapToFormValues(data: unknown): PartFormValues {
description: d.description ?? "",
selling_price: d.selling_price ?? undefined,
purchase_price: d.purchase_price ?? undefined,
+ opening_stock: d.opening_stock ?? undefined,
}
}
@@ -82,6 +85,8 @@ function mapCreatePayload(values: PartFormValues) {
description: values.description || undefined,
selling_price: values.selling_price,
purchase_price: values.purchase_price,
+ opening_stock: values.opening_stock,
+ available_stock: values.opening_stock,
}
}
@@ -221,6 +226,17 @@ export function PartForm({ resourceId, initialData, onSuccess }: PartFormProps)
+ {!isEditing && (
+
+
+
+ )}
+
diff --git a/apps/dashboard/modules/parts/parts-columns.tsx b/apps/dashboard/modules/parts/parts-columns.tsx
new file mode 100644
index 0000000..e736927
--- /dev/null
+++ b/apps/dashboard/modules/parts/parts-columns.tsx
@@ -0,0 +1,71 @@
+import { ColumnHeader } from "@/shared/data-view/table-view"
+import { Badge } from "@/shared/components/ui/badge"
+import type { ColumnDef } from "@tanstack/react-table"
+
+/** Core part columns shared between the parts page and selector dialogs. */
+export const partColumns = {
+ title: {
+ accessorKey: "title",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const r = row.original as any
+ return (
+
+ {r.title || "—"}
+ {r.sku && {r.sku}}
+
+ )
+ },
+ },
+ partNumber: {
+ accessorKey: "part_number",
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.original as any).part_number || "—",
+ },
+ manufacturer: {
+ accessorKey: "manufactured_by",
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.original as any).manufactured_by || "—",
+ },
+ sellingPrice: {
+ accessorKey: "selling_price",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const val = (row.original as any).selling_price
+ return val != null ? `$${Number(val).toFixed(2)}` : "—"
+ },
+ },
+ purchasePrice: {
+ accessorKey: "purchase_price",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const val = (row.original as any).purchase_price
+ return val != null ? `$${Number(val).toFixed(2)}` : "—"
+ },
+ },
+ stock: {
+ accessorKey: "available_stock",
+ header: ({ column }) => ,
+ cell: ({ row }) => (row.original as any).available_stock ?? "—",
+ },
+ status: {
+ accessorKey: "is_active",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const active = (row.original as any).is_active
+ return (
+
+ {active ? "Active" : "Inactive"}
+
+ )
+ },
+ },
+ createdAt: {
+ accessorKey: "created_at",
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const val = (row.original as any).created_at
+ return val ? new Date(val).toLocaleDateString() : "—"
+ },
+ },
+} satisfies Record>
diff --git a/apps/dashboard/modules/parts/parts-selector-field.tsx b/apps/dashboard/modules/parts/parts-selector-field.tsx
new file mode 100644
index 0000000..10e5d9f
--- /dev/null
+++ b/apps/dashboard/modules/parts/parts-selector-field.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import type { FieldValues, FieldPath } from "react-hook-form"
+import { Trash2 } from "lucide-react"
+import { Button } from "@/shared/components/ui/button"
+import { Input } from "@/shared/components/ui/input"
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableHead,
+ TableRow,
+ TableCell,
+} from "@/shared/components/ui/table"
+import { RhfResourceField } from "@/shared/components/resource-selector"
+import { partColumns } from "./parts-columns"
+import { PARTS_ROUTES } from "@garage/api"
+import type { PartsClient } from "@garage/api"
+
+type PartItem = {
+ part_id: number
+ title: string
+ quantity: number
+ rate: number
+ description?: string
+}
+
+type PartsItemsFieldConstraint = PartItem[] | undefined
+
+export type PartsSelectorFieldProps<
+ TValues extends FieldValues,
+ TName extends FieldPath,
+> = {
+ name: TName & (TValues[TName] extends PartsItemsFieldConstraint ? TName : never)
+ label?: string
+ triggerLabel?: string
+}
+
+export function PartsSelectorField<
+ TValues extends FieldValues,
+ TName extends FieldPath,
+>({
+ name,
+ label = "Parts",
+ triggerLabel = "Add Parts",
+}: PartsSelectorFieldProps) {
+ return (
+
+ name={name}
+ label={label}
+ triggerLabel={triggerLabel}
+ itemKey="part_id"
+ dialogProps={{
+ title: "Select Parts",
+ crudProps: {
+ routeKey: PARTS_ROUTES.INDEX,
+ getClient: (api) => api.parts,
+ columns: [
+ partColumns.title,
+ partColumns.partNumber,
+ partColumns.manufacturer,
+ partColumns.purchasePrice,
+ partColumns.stock,
+ ],
+ },
+ }}
+ mapSelected={(row) => {
+ const r = row as any
+ return {
+ part_id: r.id,
+ title: r.title || "",
+ quantity: 1,
+ rate: Number(r.purchase_price) || 0,
+ description: "",
+ } as any
+ }}
+ renderItems={(items, { remove, update }) => (
+
+ )}
+ />
+ )
+}
diff --git a/apps/dashboard/modules/purchase-orders/create-bill-from-po-button.tsx b/apps/dashboard/modules/purchase-orders/create-bill-from-po-button.tsx
new file mode 100644
index 0000000..2effd8c
--- /dev/null
+++ b/apps/dashboard/modules/purchase-orders/create-bill-from-po-button.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import { useState } from "react"
+import { FileText } from "lucide-react"
+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 { BillForm } from "@/modules/bills/bill-form"
+import { toRelation } from "@/shared/lib/utils"
+import { usePurchaseOrder } from "./purchase-order-context"
+
+/**
+ * Maps a Purchase Order data object to a complete BillFormValues shape so that
+ * useResourceForm's shallow spread (`{ ...defaultValues, ...initialData }`) correctly
+ * pre-fills all relational and line-item fields.
+ */
+function mapPOToBillInitialData(po: Record) {
+ return {
+ // Text fields
+ title: po.title ?? "",
+ 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),
+ // Link bill back to the source PO
+ purchase_order: toRelation(po.id, po.order_number ?? po.title),
+
+ // Parts pre-filled from PO items
+ part_items: (po.parts ?? []).map((p: any) => ({
+ part_id: p.part_id ?? p.id,
+ title: p.part?.title ?? p.title ?? "",
+ quantity: Number(p.quantity) || 1,
+ rate: Number(p.rate) || 0,
+ description: p.description ?? "",
+ })),
+
+ // Services and expenses start empty (PO doesn't have them)
+ service_items: [],
+ expense_items: [],
+ }
+}
+
+export function CreateBillFromPOButton() {
+ const [open, setOpen] = useState(false)
+ const poContext = usePurchaseOrder()
+
+ if (!poContext) return null
+
+ const initialData = poContext.data ? mapPOToBillInitialData(poContext.data) : undefined
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx b/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx
new file mode 100644
index 0000000..7c5cbef
--- /dev/null
+++ b/apps/dashboard/modules/purchase-orders/purchase-order-actions.tsx
@@ -0,0 +1,58 @@
+"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 { confirm } from "@/shared/components/confirm-dialog"
+import { Ellipsis, Pencil, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+
+type PurchaseOrderActionsProps = {
+ purchaseOrderId: string
+}
+
+export function PurchaseOrderActions({ purchaseOrderId }: PurchaseOrderActionsProps) {
+ const api = useAuthApi()
+ const router = useRouter()
+
+ const handleDelete = async () => {
+ const confirmed = await confirm({
+ title: "Delete this purchase order?",
+ description: "This action cannot be undone.",
+ confirmLabel: "Delete",
+ variant: "destructive",
+ })
+ if (!confirmed) return
+
+ const promise = api.purchaseOrders.destroy(purchaseOrderId)
+ toast.promise(promise, {
+ loading: "Deleting purchase order...",
+ success: "Purchase order deleted",
+ error: "Failed to delete purchase order",
+ })
+ await promise
+ router.push("/purchase/purchase-order")
+ }
+
+ return (
+
+
+
+
+
+
+
+ Delete
+
+
+
+ )
+}
diff --git a/apps/dashboard/modules/purchase-orders/purchase-order-context.tsx b/apps/dashboard/modules/purchase-orders/purchase-order-context.tsx
new file mode 100644
index 0000000..24f8090
--- /dev/null
+++ b/apps/dashboard/modules/purchase-orders/purchase-order-context.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { createContext, useContext } from "react"
+
+export type PurchaseOrderContextValue = {
+ id: string
+ label: string
+ data?: Record
+}
+
+const PurchaseOrderContext = createContext(null)
+
+export function PurchaseOrderProvider({
+ purchaseOrder,
+ children,
+}: {
+ purchaseOrder: PurchaseOrderContextValue
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function usePurchaseOrder() {
+ return useContext(PurchaseOrderContext)
+}
diff --git a/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx b/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx
index 817e2d6..410b395 100644
--- a/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx
+++ b/apps/dashboard/modules/purchase-orders/purchase-order-form.tsx
@@ -12,6 +12,7 @@ import {
RhfAsyncSelectField,
RhfDateField,
} from "@/shared/components/form"
+import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
@@ -37,12 +38,14 @@ export type PurchaseOrderFormProps = {
const DEFAULT_VALUES: PurchaseOrderFormValues = {
vendor: null,
+ order_number: "" ,
job_card: null,
department: null,
title: "",
order_date: new Date().toISOString().split("T")[0],
delivery_date: "",
notes: "",
+ items: [],
}
// ── Mapping helpers ──
@@ -57,7 +60,15 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues {
title: d.title || "",
order_date: d.order_date || "",
delivery_date: d.delivery_date || "",
+ order_number: d.order_number || "" as any,
notes: d.notes || "",
+ items: (d.parts ?? []).map((p: any) => ({
+ part_id: p.part_id ?? p.id,
+ title: p.part?.title ?? p.title ?? "",
+ quantity: p.quantity ?? 1,
+ rate: Number(p.rate) || 0,
+ description: p.description ?? "",
+ })),
}
}
@@ -68,8 +79,15 @@ function mapFormToPayload(values: PurchaseOrderFormValues) {
department_id: toId(values.department),
title: values.title,
order_date: values.order_date || undefined,
+ order_number: values.order_number,
delivery_date: values.delivery_date || undefined,
notes: values.notes || undefined,
+ items: (values.items ?? []).map((item) => ({
+ part_id: item.part_id,
+ quantity: item.quantity,
+ rate: item.rate,
+ description: item.description || undefined,
+ })),
}
}
@@ -127,9 +145,10 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
)}
-
+
+
@@ -170,6 +189,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
+ name="items" />
+