finish bills , po , jobcards relational fields creation

This commit is contained in:
2026-04-13 18:48:15 +03:00
parent 3eb471a604
commit 020ffccfd6
41 changed files with 11973 additions and 467 deletions
@@ -1,12 +1,11 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { ImportDataButton } from "@/shared/components/import-data-button"
import { ExportDataButton } from "@/shared/components/export-data-button"
import { PartForm } from "@/modules/parts/part-form"
import { Badge } from "@/shared/components/ui/badge"
import { partColumns } from "@/modules/parts/parts-columns"
import { useAuthApi } from "@/shared/useApi"
import { PARTS_ROUTES } from "@garage/api"
import type { PartsClient } from "@garage/api"
@@ -43,69 +42,17 @@ export default function PartsPage() {
),
})}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.title || "—"}</span>
{r.sku && (
<span className="ml-2 text-xs text-muted-foreground">{r.sku}</span>
)}
</div>
)
},
},
{
accessorKey: "part_number",
header: ({ column }) => <ColumnHeader column={column} title="Part #" />,
cell: ({ row }) => (row.original as any).part_number || "—",
},
{
accessorKey: "manufactured_by",
header: ({ column }) => <ColumnHeader column={column} title="Manufacturer" />,
cell: ({ row }) => (row.original as any).manufactured_by || "—",
},
{
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Cost" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
{
accessorKey: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const active = (row.original as any).is_active
return (
<Badge variant={active ? "default" : "secondary"}>
{active ? "Active" : "Inactive"}
</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() : "—"
},
},
partColumns.title,
partColumns.partNumber,
partColumns.manufacturer,
partColumns.sellingPrice,
partColumns.purchasePrice,
partColumns.stock,
partColumns.status,
partColumns.createdAt,
actionsColumn(),
]}
/>
)
}
@@ -0,0 +1,44 @@
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { PurchaseOrderActions } from '@/modules/purchase-orders/purchase-order-actions'
import { PurchaseOrderProvider } from '@/modules/purchase-orders/purchase-order-context'
import { CreateBillFromPOButton } from '@/modules/purchase-orders/create-bill-from-po-button'
import { ClipboardList } from 'lucide-react'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const purchaseOrder = (await api.purchaseOrders.getById(id)) as any
const data = purchaseOrder?.data ?? purchaseOrder
const title = data?.title || data?.order_number || 'Purchase Order'
const orderNumber = data?.order_number
const description = orderNumber ? `Order #: ${orderNumber}` : undefined
return (
<PurchaseOrderProvider purchaseOrder={{ id, label: title, data }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<ClipboardList className="size-5" />}
title={title}
description={description}
backHref="/purchase/purchase-order"
actions={
<div className="flex items-center gap-2">
<CreateBillFromPOButton />
<PurchaseOrderActions purchaseOrderId={id} />
</div>
}
tabs={[
{ href: `/purchase/purchase-order/${id}`, label: 'Details' },
]}
>
{props.children}
</DashboardDetailsPage>
</PurchaseOrderProvider>
)
}
@@ -0,0 +1,21 @@
import { getServerApi } from '@garage/api/server'
import { PurchaseOrderGeneralInfo } from '@/modules/purchase-orders/purchase-order-general-info'
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 response = await api.purchaseOrders.getById(id)
const purchaseOrder = (response as any)?.data ?? response
if (!purchaseOrder) {
return <div className="text-muted-foreground">Purchase order not found.</div>
}
return (
<DashboardPage header={null}>
<PurchaseOrderGeneralInfo purchaseOrder={purchaseOrder} />
</DashboardPage>
)
}
@@ -1,5 +1,6 @@
"use client"
import { useRouter } from "next/navigation"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
@@ -8,14 +9,17 @@ import { PURCHASE_ORDER_ROUTES } from "@garage/api"
import type { PurchaseOrdersClient } from "@garage/api"
export default function PurchaseOrdersPage() {
const router = useRouter()
return (
<ResourcePage<PurchaseOrdersClient>
pageTitle="Purchase Orders"
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
getClient={(api) => api.purchaseOrders}
onRowClick={(row) => router.push(`/purchase/purchase-order/${(row as any).id}`)}
headerProps={({ selectedItem, invalidateQuery }) => ({
actions: (
<FormDialog title="Purchase Order">
<FormDialog classNames={{dialogContent:"min-w-6xl"}} title="Purchase Order">
{(resourceId) => (
<PurchaseOrderForm
resourceId={resourceId}
+75 -2
View File
@@ -16,8 +16,11 @@ import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import { useAuthApi } from "@/shared/useApi"
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, VENDOR_ROUTES } from "@garage/api"
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, PURCHASE_ORDER_ROUTES, VENDOR_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 { billFormSchema, type BillFormValues } from "./bill.schema"
@@ -29,14 +32,19 @@ export type BillFormProps = {
const DEFAULT_VALUES: BillFormValues = {
vendor: null,
purchase_order: null,
job_card: null,
payment_term: null,
department: null,
title: "",
bill_number: "",
bill_date: "",
bill_due_date: "",
status: "draft",
notes: "",
part_items: [],
service_items: [],
expense_items: [],
}
const STATUS_OPTIONS = BillStatus.map((value) => ({
@@ -56,14 +64,37 @@ function mapToFormValues(data: unknown): BillFormValues {
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 || "",
bill_number: d.bill_number || "",
bill_date: d.bill_date || "",
bill_due_date: d.bill_due_date || "",
status: d.status || "draft",
notes: d.notes || "",
part_items: (d.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 ?? "",
})),
service_items: (d.services ?? []).map((s: any) => ({
service_id: s.service_id ?? s.id,
title: s.service?.labor_name ?? s.labor_name ?? s.title ?? "",
quantity: Number(s.quantity) || 1,
rate: Number(s.rate) || 0,
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 ?? "",
quantity: Number(e.quantity) || 1,
rate: Number(e.rate) || 0,
description: e.description ?? "",
})),
}
}
@@ -71,13 +102,33 @@ function mapFormToPayload(values: BillFormValues) {
return {
title: values.title,
vendor_id: toId(values.vendor),
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),
bill_number: values.bill_number || undefined,
bill_date: values.bill_date || undefined,
bill_due_date: values.bill_due_date || undefined,
status: values.status || undefined,
notes: values.notes || undefined,
part_items: (values.part_items ?? []).map((item) => ({
part_id: item.part_id,
quantity: item.quantity,
rate: item.rate,
description: item.description || undefined,
})),
service_items: (values.service_items ?? []).map((item) => ({
service_id: item.service_id,
quantity: item.quantity,
rate: item.rate,
description: item.description || undefined,
})),
expense_items: (values.expense_items ?? []).map((item) => ({
expense_id: item.expense_id,
quantity: item.quantity,
rate: item.rate,
description: item.description || undefined,
})),
}
}
@@ -126,6 +177,11 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
<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" />
<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" />
@@ -173,9 +229,25 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
/>
</div>
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
<RhfAsyncSelectField
name="purchase_order"
label="Purchase Order"
placeholder="Select purchase order"
queryKey={[PURCHASE_ORDER_ROUTES.INDEX]}
listFn={() => api.purchaseOrders.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.order_number || item.title || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfTextareaField name="notes" label="Notes" rows={3} />
<PartsSelectorField<BillFormValues, "part_items"> name="part_items" />
<ServicesSelectorField<BillFormValues, "service_items"> name="service_items" />
<ExpenseItemsSelectorField<BillFormValues, "expense_items"> name="expense_items" />
<Button type="submit" disabled={isPending}>
{isPending ? (
"Saving..."
@@ -195,3 +267,4 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
</Rhform>
)
}
+34 -2
View File
@@ -4,19 +4,51 @@ const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const billPartItemSchema = z.object({
part_id: z.number(),
title: z.string(),
quantity: z.number().min(1),
rate: z.number().min(0),
description: z.string().optional(),
})
const billServiceItemSchema = z.object({
service_id: z.number(),
title: z.string(),
quantity: z.number().min(1),
rate: z.number().min(0),
description: z.string().optional(),
})
const billExpenseItemSchema = z.object({
expense_id: z.number(),
title: z.string(),
quantity: z.number().min(1),
rate: z.number().min(0),
description: z.string().optional(),
})
const billFormSchema = z.object({
vendor: relationFieldSchema,
purchase_order: relationFieldSchema,
job_card: relationFieldSchema,
payment_term: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
bill_number: z.string().optional(),
bill_date: z.string().optional(),
bill_due_date: z.string().optional(),
status: z.string().optional(),
notes: z.string().optional(),
part_items: z.array(billPartItemSchema).optional(),
service_items: z.array(billServiceItemSchema).optional(),
expense_items: z.array(billExpenseItemSchema).optional(),
})
type BillFormValues = z.infer<typeof billFormSchema>
type BillPartItem = z.infer<typeof billPartItemSchema>
type BillServiceItem = z.infer<typeof billServiceItemSchema>
type BillExpenseItem = z.infer<typeof billExpenseItemSchema>
export { billFormSchema, relationFieldSchema }
export type { BillFormValues }
export { billFormSchema, relationFieldSchema, billPartItemSchema, billServiceItemSchema, billExpenseItemSchema }
export type { BillFormValues, BillPartItem, BillServiceItem, BillExpenseItem }
@@ -39,7 +39,7 @@ const SALUTATION_OPTIONS = [
export type CustomerFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
onSuccess?: (data?: any) => void
}
// ── Default values ──
@@ -146,9 +146,9 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
})
return promise
},
onSuccess: () => {
onSuccess: (data) => {
form.reset()
onSuccess?.()
onSuccess?.(data)
},
})
@@ -2,11 +2,14 @@
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Building2, Loader2 } from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Building2, Loader2, PlusIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { CUSTOMER_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
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,
@@ -15,6 +18,7 @@ import {
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { CustomerForm } from "./customer-form"
// ── Customer option type (enriched for display) ──
@@ -91,6 +95,8 @@ export function RhfCustomerSelectField<
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 {
@@ -123,14 +129,17 @@ export function RhfCustomerSelectField<
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
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 = (
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
@@ -200,6 +209,44 @@ export function RhfCustomerSelectField<
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
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>
)}
{combobox}
{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 {label}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<CustomerForm onSuccess={handleCreateSuccess} />
</ScrollArea>
</DialogContent>
</Dialog>
</Field>
)
}
@@ -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
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
<div className="space-y-4">
<RhfLabelPickerField name="labels" label="Labels" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="estimate_number" label="Estimate Number" placeholder="EST-001" />
<RhfTextField name="date" label="Date" type="date" />
</div>
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfAsyncSelectField
name="department"
label="Department"
@@ -163,21 +163,13 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncMultiSelectField
name="labels"
label="Labels"
placeholder="Select labels"
multiple
queryKey={[LABEL_ROUTES.INDEX]}
listFn={() => api.labels.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfDateField name="date" label="Date" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
<RhfTextareaField name="remarks" label="Remarks" placeholder="Enter remarks (one per line)" rows={3} />
<RhfCustomerRemarksField name="remarks" />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
@@ -185,7 +177,7 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Estimate" : "Create Estimate")}
</Button>
</FieldGroup>
</div>
</Rhform>
)
}
@@ -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<typeof estimateFormSchema>
@@ -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 }) => <ColumnHeader column={column} title="Item Name" />,
cell: ({ row }) => {
const r = row.original as any
return <span className="font-medium">{r.item_name || "—"}</span>
},
},
purchasePrice: {
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
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 }) => <ColumnHeader column={column} title="Chart of Account" />,
cell: ({ row }) => (row.original as any).purchase_chart_of_account || "—",
},
} satisfies Record<string, ColumnDef<any, any>>
@@ -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<TValues>,
> = {
name: TName & (TValues[TName] extends ExpenseItemsFieldConstraint ? TName : never)
label?: string
triggerLabel?: string
}
export function ExpenseItemsSelectorField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Expense Items",
triggerLabel = "Add Expense Items",
}: ExpenseItemsSelectorFieldProps<TValues, TName>) {
return (
<RhfResourceField<TValues, TName, ExpenseItemsClient>
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 }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Expense Item</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{((items as ExpenseLineItem[] | undefined) ?? []).map((item, index) => (
<TableRow key={item.expense_id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)
}
className="h-8 w-20"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.rate}
onChange={(e) =>
update(index, { ...item, rate: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
<TableCell>
<Input
value={item.description ?? ""}
onChange={(e) =>
update(index, { ...item, description: e.target.value } as any)
}
placeholder="Optional description"
className="h-8"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
/>
)
}
@@ -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)
</div>
{!isEditing && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="opening_stock"
label="Opening Stock"
placeholder="0"
type="number"
/>
</div>
)}
<RhfTextareaField
name="description"
label="Description"
@@ -14,6 +14,7 @@ export const partFormSchema = z.object({
description: z.string().optional(),
selling_price: z.coerce.number().min(0).optional(),
purchase_price: z.coerce.number().min(0).optional(),
opening_stock: z.coerce.number().min(0).optional(),
})
export type PartFormValues = z.infer<typeof partFormSchema>
@@ -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 }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.title || "—"}</span>
{r.sku && <span className="ml-2 text-xs text-muted-foreground">{r.sku}</span>}
</div>
)
},
},
partNumber: {
accessorKey: "part_number",
header: ({ column }) => <ColumnHeader column={column} title="Part #" />,
cell: ({ row }) => (row.original as any).part_number || "—",
},
manufacturer: {
accessorKey: "manufactured_by",
header: ({ column }) => <ColumnHeader column={column} title="Manufacturer" />,
cell: ({ row }) => (row.original as any).manufactured_by || "—",
},
sellingPrice: {
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Sell Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
purchasePrice: {
accessorKey: "purchase_price",
header: ({ column }) => <ColumnHeader column={column} title="Purchase Price" />,
cell: ({ row }) => {
const val = (row.original as any).purchase_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
stock: {
accessorKey: "available_stock",
header: ({ column }) => <ColumnHeader column={column} title="Stock" />,
cell: ({ row }) => (row.original as any).available_stock ?? "—",
},
status: {
accessorKey: "is_active",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const active = (row.original as any).is_active
return (
<Badge variant={active ? "default" : "secondary"}>
{active ? "Active" : "Inactive"}
</Badge>
)
},
},
createdAt: {
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() : "—"
},
},
} satisfies Record<string, ColumnDef<any, any>>
@@ -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<TValues>,
> = {
name: TName & (TValues[TName] extends PartsItemsFieldConstraint ? TName : never)
label?: string
triggerLabel?: string
}
export function PartsSelectorField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Parts",
triggerLabel = "Add Parts",
}: PartsSelectorFieldProps<TValues, TName>) {
return (
<RhfResourceField<TValues, TName, PartsClient>
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 }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{((items as PartItem[] | undefined) ?? []).map((item, index) => (
<TableRow key={item.part_id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)
}
className="h-8 w-20"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.rate}
onChange={(e) =>
update(index, { ...item, rate: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
<TableCell>
<Input
value={item.description ?? ""}
onChange={(e) =>
update(index, { ...item, description: e.target.value } as any)
}
placeholder="Optional description"
className="h-8"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
/>
)
}
@@ -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<string, any>) {
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 (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileText className="me-2 size-4" />
Create Bill
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="min-w-6xl">
<DialogHeader>
<DialogTitle>Create Bill from Purchase Order</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[75vh] px-1">
<BillForm
initialData={initialData}
onSuccess={() => setOpen(false)}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,29 @@
"use client"
import { createContext, useContext } from "react"
export type PurchaseOrderContextValue = {
id: string
label: string
data?: Record<string, any>
}
const PurchaseOrderContext = createContext<PurchaseOrderContextValue | null>(null)
export function PurchaseOrderProvider({
purchaseOrder,
children,
}: {
purchaseOrder: PurchaseOrderContextValue
children: React.ReactNode
}) {
return (
<PurchaseOrderContext.Provider value={purchaseOrder}>
{children}
</PurchaseOrderContext.Provider>
)
}
export function usePurchaseOrder() {
return useContext(PurchaseOrderContext)
}
@@ -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
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<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 />
<RhfDateField name="order_date" label="Order Date" />
<RhfDateField name="delivery_date" label="Delivery Date" />
</div>
@@ -170,6 +189,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
<RhfTextareaField name="notes" label="Notes" rows={3} />
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? (
"Saving..."
@@ -0,0 +1,288 @@
import {
ClipboardList,
Calendar,
Hash,
Building2,
Truck,
FileText,
Package,
DollarSign,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/shared/components/ui/table"
type PurchaseOrderPart = {
id?: number
purchase_order_id?: number
part_id?: number
quantity?: number
rate?: string
description?: string
created_at?: string
updated_at?: string
part?: {
id?: number
title?: string
}
}
type PurchaseOrderData = {
id?: number
job_card_id?: number
vendor_id?: number
vendor_name?: string
job_card_name?: string
department_name?: string
title?: string
order_number?: string
order_date?: string
delivery_date?: string
department_id?: number
notes?: string
terms_and_conditions?: string
created_at?: string
updated_at?: string
parts?: PurchaseOrderPart[]
}
type PurchaseOrderGeneralInfoProps = {
purchaseOrder: PurchaseOrderData
}
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>
)
}
function formatDate(dateStr?: string | null) {
if (!dateStr) return null
return new Date(dateStr).toLocaleDateString()
}
function formatCurrency(value?: string | number | null) {
if (value == null) return "—"
return `$${Number(value).toFixed(2)}`
}
export function PurchaseOrderGeneralInfo({ purchaseOrder }: PurchaseOrderGeneralInfoProps) {
const parts = purchaseOrder.parts ?? []
const totalAmount = parts.reduce(
(sum, p) => sum + (p.quantity ?? 0) * Number(p.rate ?? 0),
0,
)
return (
<div className="grid gap-6">
{/* Top row: Order Info + Dates */}
<div className="grid gap-6 md:grid-cols-2">
{/* Order Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4" />
Order Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{purchaseOrder.order_number && (
<Badge variant="secondary">{purchaseOrder.order_number}</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Hash}
label="Order Number"
value={purchaseOrder.order_number}
/>
<InfoItem
icon={ClipboardList}
label="Title"
value={purchaseOrder.title}
/>
<InfoItem
icon={Truck}
label="Vendor"
value={purchaseOrder.vendor_name ?? (purchaseOrder.vendor_id ? `Vendor #${purchaseOrder.vendor_id}` : null)}
/>
<InfoItem
icon={Building2}
label="Department"
value={purchaseOrder.department_name ?? (purchaseOrder.department_id ? `Dept #${purchaseOrder.department_id}` : null)}
/>
</div>
</CardContent>
</Card>
{/* Dates & Notes */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="size-4" />
Dates & Notes
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Order Date"
value={formatDate(purchaseOrder.order_date)}
/>
<InfoItem
icon={Calendar}
label="Delivery Date"
value={formatDate(purchaseOrder.delivery_date)}
/>
<InfoItem
icon={Calendar}
label="Created"
value={formatDate(purchaseOrder.created_at)}
/>
<InfoItem
icon={Calendar}
label="Updated"
value={formatDate(purchaseOrder.updated_at)}
/>
</div>
{purchaseOrder.notes && (
<>
<Separator />
<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">
<FileText className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Notes</span>
<p className="text-sm whitespace-pre-wrap">{purchaseOrder.notes}</p>
</div>
</div>
</>
)}
{purchaseOrder.terms_and_conditions && (
<>
<Separator />
<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">
<FileText className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Terms & Conditions</span>
<p className="text-sm whitespace-pre-wrap">{purchaseOrder.terms_and_conditions}</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Parts / Line Items */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Package className="size-4" />
Parts ({parts.length})
</span>
<span className="flex items-center gap-2 text-base">
<DollarSign className="size-4" />
Total: {formatCurrency(totalAmount)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{parts.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Part</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parts.map((item, index) => {
const amount = (item.quantity ?? 0) * Number(item.rate ?? 0)
return (
<TableRow key={item.id ?? index}>
<TableCell className="text-muted-foreground">
{index + 1}
</TableCell>
<TableCell className="font-medium">
{item.part?.title ?? `Part #${item.part_id}`}
</TableCell>
<TableCell className="text-muted-foreground">
{item.description || "—"}
</TableCell>
<TableCell className="text-right">
{item.quantity ?? 0}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.rate)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(amount)}
</TableCell>
</TableRow>
)
})}
<TableRow className="bg-muted/50 font-semibold">
<TableCell colSpan={5} className="text-right">
Total
</TableCell>
<TableCell className="text-right">
{formatCurrency(totalAmount)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
No parts added to this purchase order.
</p>
)}
</CardContent>
</Card>
</div>
)
}
@@ -4,6 +4,14 @@ const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const purchaseOrderItemSchema = z.object({
part_id: z.number(),
title: z.string(),
quantity: z.number().min(1, "Quantity must be at least 1"),
rate: z.number().min(0, "Rate must be non-negative"),
description: z.string().optional(),
})
const purchaseOrderFormSchema = z.object({
// ── Relations ──
vendor: relationFieldSchema,
@@ -15,9 +23,15 @@ const purchaseOrderFormSchema = z.object({
order_date: z.string().optional(),
delivery_date: z.string().optional(),
notes: z.string().optional(),
order_number: z.string().min(1, "Order number is required"),
// ── Items (parts) ──
items: z.array(purchaseOrderItemSchema).optional(),
})
type PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
type PurchaseOrderItem = z.infer<typeof purchaseOrderItemSchema>
export { purchaseOrderFormSchema, relationFieldSchema }
export type { PurchaseOrderFormValues }
export { purchaseOrderFormSchema, purchaseOrderItemSchema, relationFieldSchema }
export type { PurchaseOrderFormValues, PurchaseOrderItem }
@@ -0,0 +1,37 @@
import { ColumnHeader } from "@/shared/data-view/table-view"
import type { ColumnDef } from "@tanstack/react-table"
/** Core service columns shared between the services page and selector dialogs. */
export const serviceColumns = {
name: {
accessorKey: "labor_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const r = row.original as any
return (
<div>
<span className="font-medium">{r.labor_name || r.name || "—"}</span>
{r.service_code && (
<span className="ml-2 text-xs text-muted-foreground">{r.service_code}</span>
)}
</div>
)
},
},
description: {
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
cell: ({ row }) => {
const val = (row.original as any).description
return val ? <span className="max-w-48 truncate block">{val}</span> : "—"
},
},
sellingPrice: {
accessorKey: "selling_price",
header: ({ column }) => <ColumnHeader column={column} title="Price" />,
cell: ({ row }) => {
const val = (row.original as any).selling_price
return val != null ? `$${Number(val).toFixed(2)}` : "—"
},
},
} satisfies Record<string, ColumnDef<any, any>>
@@ -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 { serviceColumns } from "./services-columns"
import { SERVICE_ROUTES } from "@garage/api"
import type { ServicesClient } from "@garage/api"
type ServiceLineItem = {
service_id: number
title: string
quantity: number
rate: number
description?: string
}
type ServiceItemsFieldConstraint = ServiceLineItem[] | undefined
export type ServicesSelectorFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName & (TValues[TName] extends ServiceItemsFieldConstraint ? TName : never)
label?: string
triggerLabel?: string
}
export function ServicesSelectorField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Services",
triggerLabel = "Add Services",
}: ServicesSelectorFieldProps<TValues, TName>) {
return (
<RhfResourceField<TValues, TName, ServicesClient>
name={name}
label={label}
triggerLabel={triggerLabel}
itemKey="service_id"
dialogProps={{
title: "Select Services",
crudProps: {
routeKey: SERVICE_ROUTES.INDEX,
getClient: (api) => api.services,
columns: [
serviceColumns.name,
serviceColumns.description,
serviceColumns.sellingPrice,
],
},
}}
mapSelected={(row) => {
const r = row as any
return {
service_id: r.id,
title: r.labor_name || r.name || "",
quantity: 1,
rate: Number(r.selling_price) || 0,
description: "",
} as any
}}
renderItems={(items, { remove, update }) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead className="w-24">Qty</TableHead>
<TableHead className="w-28">Rate</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{((items as ServiceLineItem[] | undefined) ?? []).map((item, index) => (
<TableRow key={item.service_id}>
<TableCell className="font-medium">{item.title}</TableCell>
<TableCell>
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
update(index, { ...item, quantity: Number(e.target.value) || 1 } as any)
}
className="h-8 w-20"
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
step={0.01}
value={item.rate}
onChange={(e) =>
update(index, { ...item, rate: Number(e.target.value) || 0 } as any)
}
className="h-8 w-24"
/>
</TableCell>
<TableCell>
<Input
value={item.description ?? ""}
onChange={(e) =>
update(index, { ...item, description: e.target.value } as any)
}
placeholder="Optional description"
className="h-8"
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
/>
)
}
@@ -2,11 +2,14 @@
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Car, Loader2 } from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Car, Loader2, PlusIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { VEHICLE_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
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,
@@ -15,8 +18,8 @@ import {
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { Badge } from "@/shared/components/ui/badge"
import Image from "next/image"
import { VehicleForm } from "./vehicle-form"
// ── Vehicle option type (enriched for display) ──
@@ -87,6 +90,8 @@ export function RhfVehicleSelectField<
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 {
@@ -113,14 +118,17 @@ export function RhfVehicleSelectField<
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
const handleCreateSuccess = (data?: any) => {
const item = data?.data ?? data
if (item?.id) {
field.onChange(buildVehicleOption(item))
}
queryClient.invalidateQueries({ queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"] })
setIsCreateOpen(false)
}
const combobox = (
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
@@ -204,6 +212,44 @@ export function RhfVehicleSelectField<
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
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>
)}
{combobox}
{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 {label}
</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<VehicleForm onSuccess={handleCreateSuccess} />
</ScrollArea>
</DialogContent>
</Dialog>
</Field>
)
}
@@ -32,7 +32,7 @@ import { VEHICLE_ROUTES } from "@garage/api"
export type VehicleFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
onSuccess?: (data?: any) => void
}
// ── Default values ──
@@ -138,9 +138,9 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
})
return promise
},
onSuccess: () => {
onSuccess: (data) => {
form.reset()
onSuccess?.()
onSuccess?.(data)
},
})
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { AUTO_GENERATE_ROUTES } from "@garage/api"
import { AUTO_GENERATE_ROUTES, Tables } from "@garage/api"
import { FieldShell } from "../field-shell"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
@@ -21,7 +21,7 @@ type RhfAutoGenerateFieldProps<
required?: boolean
disabled?: boolean
placeholder?: string
table: string
table: Tables
/** When true, fetches the next code immediately on mount */
autoFetch?: boolean
}
@@ -3,6 +3,7 @@
import type { FieldValues } from "react-hook-form"
import { FormProvider } from "react-hook-form"
import type { RhformProps } from "./types"
import { cn } from "@/shared/lib/utils"
export function Rhform<TValues extends FieldValues>({
form,
@@ -15,7 +16,7 @@ export function Rhform<TValues extends FieldValues>({
<form
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
noValidate
className={className}
className={cn('p-1',className)}
>
{children}
</form>
@@ -0,0 +1,2 @@
export { ResourceSelectorDialog, type ResourceSelectorDialogProps } from "./resource-selector-dialog"
export { RhfResourceField, type RhfResourceFieldProps } from "./rhf-resource-field"
@@ -0,0 +1,93 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog"
import { CrudResource, type CrudResourceProps } from "@/shared/data-view/resource-page"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
import { Card, CardContent } from "@/shared/components/ui/card"
export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
/** Dialog title shown in the header */
title: string
/** Opens/closes the dialog */
open: boolean
onOpenChange: (open: boolean) => void
/** Called when user confirms selection */
onConfirm: (selectedRows: ResourceItem<TClient>[]) => void
/** CrudResource config for the table (columns, routeKey, getClient, etc.) */
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
/** Optional rowKey for selection identity. Defaults to "id" */
rowKey?: keyof ResourceItem<TClient>
}
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
title,
open,
onOpenChange,
onConfirm,
crudProps,
rowKey,
}: ResourceSelectorDialogProps<TClient>) {
type TItem = ResourceItem<TClient>
const selectedRef = useRef<TItem[]>([])
const [count, setCount] = useState(0)
const handleSelectionChange = (rows: TItem[]) => {
selectedRef.current = rows
setCount(rows.length)
}
const handleConfirm = () => {
onConfirm(selectedRef.current)
selectedRef.current = []
setCount(0)
onOpenChange(false)
}
const handleCancel = () => {
selectedRef.current = []
setCount(0)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<Card className="rounded-none border-0 shadow-none">
<CardContent className="p-0">
<CrudResource<TClient>
{...crudProps}
tableProps={{
selection: {
onSelectionChange: handleSelectionChange,
...(rowKey ? { rowKey } : {}),
},
}}
/>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={count === 0}>
Confirm{count > 0 ? ` (${count})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,131 @@
"use client"
import { useState, type ReactNode } from "react"
import { useController, useFormContext, type FieldValues, type FieldPath } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Field, FieldError } from "@/shared/components/ui/field"
import { Plus } from "lucide-react"
import {
ResourceSelectorDialog,
type ResourceSelectorDialogProps,
} from "./resource-selector-dialog"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
export type RhfResourceFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
> = {
/** RHF field name — value is an array of items after mapping */
name: TName
/** Label displayed on the card header */
label: string
/** Button text for the trigger */
triggerLabel?: string
/** Map a selected resource row into the form item shape */
mapSelected: (row: ResourceItem<TClient>) => TValues[TName][number]
/** Render the list of selected items inside the card */
renderItems: (
items: TValues[TName],
helpers: {
remove: (index: number) => void
update: (index: number, item: TValues[TName][number]) => void
replace: (items: TValues[TName]) => void
},
) => ReactNode
/** Props forwarded to ResourceSelectorDialog (columns, routeKey, getClient, etc.) */
dialogProps: Omit<
ResourceSelectorDialogProps<TClient>,
"open" | "onOpenChange" | "onConfirm"
>
/** Deduplicate by this key when merging new selections. Defaults to "id" */
itemKey?: string
}
export function RhfResourceField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
TClient extends ResourcePageClient,
>({
name,
label,
triggerLabel,
mapSelected,
renderItems,
dialogProps,
itemKey = "id",
}: RhfResourceFieldProps<TValues, TName, TClient>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control })
const [open, setOpen] = useState(false)
const items: TValues[TName] = field.value ?? ([] as unknown as TValues[TName])
const handleConfirm = (rows: ResourceItem<TClient>[]) => {
const mapped = rows.map(mapSelected)
// Merge: keep existing items, add new ones (deduplicate by itemKey)
const existingKeys = new Set(
(items as any[]).map((item: any) => String(item[itemKey])),
)
const newItems = mapped.filter(
(item: any) => !existingKeys.has(String(item[itemKey])),
)
field.onChange([...items, ...newItems])
}
const helpers = {
remove: (index: number) => {
const next = [...(items as any[])]
next.splice(index, 1)
field.onChange(next)
},
update: (index: number, item: TValues[TName][number]) => {
const next = [...(items as any[])]
next[index] = item
field.onChange(next)
},
replace: (newItems: TValues[TName]) => {
field.onChange(newItems)
},
}
return (
<Field data-invalid={!!error || undefined}>
<Card>
<CardHeader className=" flex justify-between items-center">
<span className="text-base font-medium">{label}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setOpen(true)}
>
<Plus className="mr-1.5 h-4 w-4" />
{triggerLabel ?? label}
</Button>
</CardHeader>
<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>
)}
</CardContent>
</Card>
{error && <FieldError>{error.message}</FieldError>}
<ResourceSelectorDialog<TClient>
{...dialogProps}
open={open}
onOpenChange={setOpen}
onConfirm={handleConfirm}
/>
</Field>
)
}
@@ -1,7 +1,7 @@
"use client"
import React from "react"
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
import { DataTable, type ActionsColumnOptions, type DataViewProps } from "@/shared/data-view/table-view"
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
import type { ColumnDef } from "@tanstack/react-table"
@@ -28,10 +28,13 @@ type ReactNodeOrRender<TClient extends ResourcePageClient> =
| React.ReactNode
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
type ManagedTableProps = "columns" | "data" | "pagination" | "sorting" | "onChange" | "isLoading"
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
onRowClick?: (row: ResourceItem<TClient>) => void
tableHeader?: ReactNodeOrRender<TClient>
tableProps?: Omit<Partial<DataViewProps<ResourceItem<TClient>>>, ManagedTableProps>
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
}
@@ -44,6 +47,7 @@ export function CrudResource<TClient extends ResourcePageClient>({
extraParams,
onRowClick,
tableHeader,
tableProps,
render,
}: CrudResourceProps<TClient>) {
type TItem = ResourceItem<TClient>
@@ -79,13 +83,14 @@ export function CrudResource<TClient extends ResourcePageClient>({
<>
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
<DataTable
{...tableProps}
columns={columns}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick}
onRowClick={onRowClick ?? tableProps?.onRowClick}
/>
</>
)
@@ -1,10 +1,12 @@
"use client"
import { useState, useMemo, useEffect, useRef } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
} from "@tanstack/react-table"
import {
Table,
@@ -15,8 +17,9 @@ import {
TableRow,
TableCell,
} from "@/shared/components/ui/table"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { DataViewProvider } from "./data-view-context"
import type { DataViewProps } from "./types"
import type { DataViewProps } from "./types"
import { DataViewPagination } from "./data-view-pagination"
import { Skeleton } from "@/shared/components/ui/skeleton"
@@ -29,20 +32,83 @@ export function DataTable<TData>({
isLoading = false,
onRowClick,
slots,
selection,
}: DataViewProps<TData>) {
const rowKeyStr = (selection?.rowKey as string) ?? "id"
// Persisted map of id → original row data across all pages
const persistedMap = useRef<Map<string, TData>>(new Map())
// Current-page selection state that TanStack Table controls
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// When the page/data changes, restore selection state for the new page from the map
useEffect(() => {
if (!selection) return
const restored: RowSelectionState = {}
data.forEach((row) => {
const id = String((row as Record<string, unknown>)[rowKeyStr])
if (persistedMap.current.has(id)) restored[id] = true
})
setRowSelection(restored)
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const selectionColumn: ColumnDef<TData, unknown> = useMemo(
() => ({
id: "__select__",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
onClick={(e) => e.stopPropagation()}
/>
),
size: 40,
enableSorting: false,
}),
[],
)
const resolvedColumns = useMemo(
() =>
selection
? [selectionColumn, ...(columns as ColumnDef<TData, unknown>[])]
: (columns as ColumnDef<TData, unknown>[]),
[selection, columns, selectionColumn],
)
const table = useReactTable({
data,
columns: columns as ColumnDef<TData, unknown>[],
columns: resolvedColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
pageCount: pagination.pageCount,
enableRowSelection: !!selection,
getRowId: selection
? (row) => String((row as Record<string, unknown>)[(selection.rowKey as string) ?? "id"])
: undefined,
state: {
sorting,
pagination: {
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize,
},
rowSelection,
},
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater
@@ -61,6 +127,22 @@ export function DataTable<TData>({
},
})
},
onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater
setRowSelection(next)
if (selection) {
// Sync current page into the persisted map
data.forEach((row) => {
const id = String((row as Record<string, unknown>)[rowKeyStr])
if (next[id]) {
persistedMap.current.set(id, row)
} else {
persistedMap.current.delete(id)
}
})
selection.onSelectionChange(Array.from(persistedMap.current.values()))
}
},
})
return (
@@ -26,6 +26,12 @@ export type DataViewSlots = {
footer?: ReactNode
}
export type DataViewSelection<TData> = {
onSelectionChange: (selectedRows: TData[]) => void
/** Field used as the unique row identifier. Defaults to "id". */
rowKey?: keyof TData
}
export type DataViewProps<TData> = {
columns: ColumnDef<TData, any>[]
data: TData[]
@@ -35,4 +41,5 @@ export type DataViewProps<TData> = {
isLoading?: boolean
onRowClick?: (row: TData) => void
slots?: DataViewSlots
selection?: DataViewSelection<TData>
}