finish bills , po , jobcards relational fields creation
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user