finish bills , po , jobcards relational fields creation
This commit is contained in:
parent
3eb471a604
commit
020ffccfd6
@ -1,12 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
import { ImportDataButton } from "@/shared/components/import-data-button"
|
import { ImportDataButton } from "@/shared/components/import-data-button"
|
||||||
import { ExportDataButton } from "@/shared/components/export-data-button"
|
import { ExportDataButton } from "@/shared/components/export-data-button"
|
||||||
import { PartForm } from "@/modules/parts/part-form"
|
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 { useAuthApi } from "@/shared/useApi"
|
||||||
import { PARTS_ROUTES } from "@garage/api"
|
import { PARTS_ROUTES } from "@garage/api"
|
||||||
import type { PartsClient } from "@garage/api"
|
import type { PartsClient } from "@garage/api"
|
||||||
@ -43,69 +42,17 @@ export default function PartsPage() {
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
columns={({ actionsColumn }) => [
|
columns={({ actionsColumn }) => [
|
||||||
{
|
partColumns.title,
|
||||||
accessorKey: "title",
|
partColumns.partNumber,
|
||||||
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
|
partColumns.manufacturer,
|
||||||
cell: ({ row }) => {
|
partColumns.sellingPrice,
|
||||||
const r = row.original as any
|
partColumns.purchasePrice,
|
||||||
return (
|
partColumns.stock,
|
||||||
<div>
|
partColumns.status,
|
||||||
<span className="font-medium">{r.title || "—"}</span>
|
partColumns.createdAt,
|
||||||
{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() : "—"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actionsColumn(),
|
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"
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { ResourcePage } from "@/shared/data-view/resource-page"
|
import { ResourcePage } from "@/shared/data-view/resource-page"
|
||||||
import { ColumnHeader } from "@/shared/data-view/table-view"
|
import { ColumnHeader } from "@/shared/data-view/table-view"
|
||||||
import FormDialog from "@/shared/components/form-dialog"
|
import FormDialog from "@/shared/components/form-dialog"
|
||||||
@ -8,14 +9,17 @@ import { PURCHASE_ORDER_ROUTES } from "@garage/api"
|
|||||||
import type { PurchaseOrdersClient } from "@garage/api"
|
import type { PurchaseOrdersClient } from "@garage/api"
|
||||||
|
|
||||||
export default function PurchaseOrdersPage() {
|
export default function PurchaseOrdersPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourcePage<PurchaseOrdersClient>
|
<ResourcePage<PurchaseOrdersClient>
|
||||||
pageTitle="Purchase Orders"
|
pageTitle="Purchase Orders"
|
||||||
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
routeKey={PURCHASE_ORDER_ROUTES.INDEX}
|
||||||
getClient={(api) => api.purchaseOrders}
|
getClient={(api) => api.purchaseOrders}
|
||||||
|
onRowClick={(row) => router.push(`/purchase/purchase-order/${(row as any).id}`)}
|
||||||
headerProps={({ selectedItem, invalidateQuery }) => ({
|
headerProps={({ selectedItem, invalidateQuery }) => ({
|
||||||
actions: (
|
actions: (
|
||||||
<FormDialog title="Purchase Order">
|
<FormDialog classNames={{dialogContent:"min-w-6xl"}} title="Purchase Order">
|
||||||
{(resourceId) => (
|
{(resourceId) => (
|
||||||
<PurchaseOrderForm
|
<PurchaseOrderForm
|
||||||
resourceId={resourceId}
|
resourceId={resourceId}
|
||||||
|
|||||||
@ -16,8 +16,11 @@ import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
|||||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||||
import { toId, toRelation } from "@/shared/lib/utils"
|
import { toId, toRelation } from "@/shared/lib/utils"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, 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 { 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"
|
import { billFormSchema, type BillFormValues } from "./bill.schema"
|
||||||
|
|
||||||
@ -29,14 +32,19 @@ export type BillFormProps = {
|
|||||||
|
|
||||||
const DEFAULT_VALUES: BillFormValues = {
|
const DEFAULT_VALUES: BillFormValues = {
|
||||||
vendor: null,
|
vendor: null,
|
||||||
|
purchase_order: null,
|
||||||
job_card: null,
|
job_card: null,
|
||||||
payment_term: null,
|
payment_term: null,
|
||||||
department: null,
|
department: null,
|
||||||
title: "",
|
title: "",
|
||||||
|
bill_number: "",
|
||||||
bill_date: "",
|
bill_date: "",
|
||||||
bill_due_date: "",
|
bill_due_date: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
part_items: [],
|
||||||
|
service_items: [],
|
||||||
|
expense_items: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = BillStatus.map((value) => ({
|
const STATUS_OPTIONS = BillStatus.map((value) => ({
|
||||||
@ -56,14 +64,37 @@ function mapToFormValues(data: unknown): BillFormValues {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
vendor: toRelation(d.vendor_id, d.vendor_name),
|
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),
|
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),
|
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
|
||||||
department: toRelation(d.department_id, d.department_name),
|
department: toRelation(d.department_id, d.department_name),
|
||||||
title: d.title || "",
|
title: d.title || "",
|
||||||
|
bill_number: d.bill_number || "",
|
||||||
bill_date: d.bill_date || "",
|
bill_date: d.bill_date || "",
|
||||||
bill_due_date: d.bill_due_date || "",
|
bill_due_date: d.bill_due_date || "",
|
||||||
status: d.status || "draft",
|
status: d.status || "draft",
|
||||||
notes: d.notes || "",
|
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 {
|
return {
|
||||||
title: values.title,
|
title: values.title,
|
||||||
vendor_id: toId(values.vendor),
|
vendor_id: toId(values.vendor),
|
||||||
|
purchase_order_id: toId(values.purchase_order),
|
||||||
job_card_id: toId(values.job_card),
|
job_card_id: toId(values.job_card),
|
||||||
payment_terms_id: toId(values.payment_term),
|
payment_terms_id: toId(values.payment_term),
|
||||||
department_id: toId(values.department),
|
department_id: toId(values.department),
|
||||||
|
bill_number: values.bill_number || undefined,
|
||||||
bill_date: values.bill_date || undefined,
|
bill_date: values.bill_date || undefined,
|
||||||
bill_due_date: values.bill_due_date || undefined,
|
bill_due_date: values.bill_due_date || undefined,
|
||||||
status: values.status || undefined,
|
status: values.status || undefined,
|
||||||
notes: values.notes || 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>
|
<FieldGroup>
|
||||||
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
|
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfTextField name="bill_date" label="Bill Date" type="date" />
|
<RhfTextField name="bill_date" label="Bill Date" type="date" />
|
||||||
<RhfTextField name="bill_due_date" label="Due 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>
|
</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} />
|
<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}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
"Saving..."
|
"Saving..."
|
||||||
@ -195,3 +267,4 @@ export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps)
|
|||||||
</Rhform>
|
</Rhform>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,19 +4,51 @@ const relationFieldSchema = z
|
|||||||
.object({ value: z.string(), label: z.string() })
|
.object({ value: z.string(), label: z.string() })
|
||||||
.nullable()
|
.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({
|
const billFormSchema = z.object({
|
||||||
vendor: relationFieldSchema,
|
vendor: relationFieldSchema,
|
||||||
|
purchase_order: relationFieldSchema,
|
||||||
job_card: relationFieldSchema,
|
job_card: relationFieldSchema,
|
||||||
payment_term: relationFieldSchema,
|
payment_term: relationFieldSchema,
|
||||||
department: relationFieldSchema,
|
department: relationFieldSchema,
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
|
bill_number: z.string().optional(),
|
||||||
bill_date: z.string().optional(),
|
bill_date: z.string().optional(),
|
||||||
bill_due_date: z.string().optional(),
|
bill_due_date: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
notes: 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 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 { billFormSchema, relationFieldSchema, billPartItemSchema, billServiceItemSchema, billExpenseItemSchema }
|
||||||
export type { BillFormValues }
|
export type { BillFormValues, BillPartItem, BillServiceItem, BillExpenseItem }
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const SALUTATION_OPTIONS = [
|
|||||||
export type CustomerFormProps = {
|
export type CustomerFormProps = {
|
||||||
resourceId?: string | null
|
resourceId?: string | null
|
||||||
initialData?: unknown
|
initialData?: unknown
|
||||||
onSuccess?: () => void
|
onSuccess?: (data?: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default values ──
|
// ── Default values ──
|
||||||
@ -146,9 +146,9 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
|
|||||||
})
|
})
|
||||||
return promise
|
return promise
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
form.reset()
|
form.reset()
|
||||||
onSuccess?.()
|
onSuccess?.(data)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Building2, Loader2 } from "lucide-react"
|
import { Building2, Loader2, PlusIcon } from "lucide-react"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { CUSTOMER_ROUTES } from "@garage/api"
|
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 {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
@ -15,6 +18,7 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxEmpty,
|
ComboboxEmpty,
|
||||||
} from "@/shared/components/ui/combobox"
|
} from "@/shared/components/ui/combobox"
|
||||||
|
import { CustomerForm } from "./customer-form"
|
||||||
|
|
||||||
// ── Customer option type (enriched for display) ──
|
// ── Customer option type (enriched for display) ──
|
||||||
|
|
||||||
@ -91,6 +95,8 @@ export function RhfCustomerSelectField<
|
|||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const anchorRef = useRef<HTMLDivElement>(null)
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
const [inputValue, setInputValue] = useState("")
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { control } = useFormContext<TValues>()
|
const { control } = useFormContext<TValues>()
|
||||||
const {
|
const {
|
||||||
@ -123,14 +129,17 @@ export function RhfCustomerSelectField<
|
|||||||
)
|
)
|
||||||
: options
|
: options
|
||||||
|
|
||||||
return (
|
const handleCreateSuccess = (data?: any) => {
|
||||||
<FieldShell
|
const item = data?.data ?? data
|
||||||
label={label}
|
if (item?.id) {
|
||||||
error={error?.message}
|
field.onChange(buildCustomerOption(item))
|
||||||
description={description}
|
}
|
||||||
required={required}
|
queryClient.invalidateQueries({ queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"] })
|
||||||
>
|
setIsCreateOpen(false)
|
||||||
<div ref={anchorRef}>
|
}
|
||||||
|
|
||||||
|
const combobox = (
|
||||||
|
<div ref={anchorRef}>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
|
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
|
||||||
@ -200,6 +209,44 @@ export function RhfCustomerSelectField<
|
|||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</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 { Button } from "@/shared/components/ui/button"
|
||||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||||
import { FieldGroup } from "@/shared/components/ui/field"
|
|
||||||
import {
|
import {
|
||||||
Rhform,
|
Rhform,
|
||||||
RhfTextField,
|
RhfTextField,
|
||||||
RhfTextareaField,
|
|
||||||
RhfCheckboxField,
|
RhfCheckboxField,
|
||||||
RhfAsyncSelectField,
|
RhfAsyncSelectField,
|
||||||
RhfAsyncMultiSelectField,
|
RhfDateField,
|
||||||
|
RhfAutoGenerateField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
@ -23,9 +22,11 @@ import {
|
|||||||
estimateFormSchema,
|
estimateFormSchema,
|
||||||
type EstimateFormValues,
|
type EstimateFormValues,
|
||||||
} from "./estimate.schema"
|
} 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 { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||||
|
import { RhfLabelPickerField, type LabelItem } from "@/modules/labels/rhf-label-picker-field"
|
||||||
|
import { RhfCustomerRemarksField } from "./rhf-customer-remarks-field"
|
||||||
|
|
||||||
// ── Props ──
|
// ── Props ──
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ const DEFAULT_VALUES: EstimateFormValues = {
|
|||||||
estimate_number: "",
|
estimate_number: "",
|
||||||
date: "",
|
date: "",
|
||||||
has_insurance: false,
|
has_insurance: false,
|
||||||
remarks: "",
|
remarks: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,12 +62,16 @@ function mapToFormValues(data: unknown): EstimateFormValues {
|
|||||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||||
department: toRelation(d.department_id, d.department_name),
|
department: toRelation(d.department_id, d.department_name),
|
||||||
estimate_number: d.estimate_number || "",
|
estimate_number: d.estimate_number || "",
|
||||||
date: d.date || "",
|
date: d.date ? d.date.split("T")[0] : "",
|
||||||
has_insurance: d.has_insurance ?? false,
|
has_insurance: d.has_insurance ?? false,
|
||||||
remarks: Array.isArray(d.remarks) ? d.remarks.join("\n") : d.remarks || "",
|
remarks: Array.isArray(d.remarks)
|
||||||
labels: Array.isArray(d.labels)
|
? d.remarks.map((r: any) => (typeof r === "string" ? r : (r?.remark ?? ""))).filter(Boolean)
|
||||||
? d.labels.map((l: any) => ({ value: String(l.id), label: l.name }))
|
|
||||||
: [],
|
: [],
|
||||||
|
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,
|
estimate_number: values.estimate_number || undefined,
|
||||||
date: values.date || undefined,
|
date: values.date || undefined,
|
||||||
has_insurance: values.has_insurance,
|
has_insurance: values.has_insurance,
|
||||||
remarks: values.remarks
|
remarks: values.remarks?.filter(Boolean) ?? [],
|
||||||
? values.remarks.split("\n").filter(Boolean)
|
label_ids: values.labels?.map((l) => l.id) ?? [],
|
||||||
: [],
|
|
||||||
label_ids: values.labels.map((l) => Number(l.value)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,20 +143,17 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FieldGroup>
|
<div className="space-y-4">
|
||||||
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
|
<RhfLabelPickerField name="labels" label="Labels" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<RhfTextField name="title" label="Title" placeholder="Estimate title" required />
|
||||||
<RhfTextField name="estimate_number" label="Estimate Number" placeholder="EST-001" />
|
|
||||||
<RhfTextField name="date" label="Date" type="date" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<RhfCustomerSelectField name="customer" />
|
<RhfCustomerSelectField name="customer" />
|
||||||
<RhfVehicleSelectField name="vehicle" />
|
<RhfVehicleSelectField name="vehicle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<RhfAsyncSelectField
|
<RhfAsyncSelectField
|
||||||
name="department"
|
name="department"
|
||||||
label="Department"
|
label="Department"
|
||||||
@ -163,21 +163,13 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
|||||||
mapOption={mapLookupOption}
|
mapOption={mapLookupOption}
|
||||||
{...STORE_OBJECT}
|
{...STORE_OBJECT}
|
||||||
/>
|
/>
|
||||||
<RhfAsyncMultiSelectField
|
<RhfDateField name="date" label="Date" />
|
||||||
name="labels"
|
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
|
||||||
label="Labels"
|
|
||||||
placeholder="Select labels"
|
|
||||||
multiple
|
|
||||||
queryKey={[LABEL_ROUTES.INDEX]}
|
|
||||||
listFn={() => api.labels.list()}
|
|
||||||
mapOption={mapLookupOption}
|
|
||||||
{...STORE_OBJECT}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RhfCheckboxField name="has_insurance" label="Has Insurance" />
|
<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}>
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
{isEditing ? <Save /> : <Plus />}
|
{isEditing ? <Save /> : <Plus />}
|
||||||
@ -185,7 +177,7 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
|
|||||||
? (isEditing ? "Updating..." : "Creating...")
|
? (isEditing ? "Updating..." : "Creating...")
|
||||||
: (isEditing ? "Update Estimate" : "Create Estimate")}
|
: (isEditing ? "Update Estimate" : "Create Estimate")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</div>
|
||||||
</Rhform>
|
</Rhform>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,20 @@ const estimateFormSchema = z.object({
|
|||||||
estimate_number: z.string().optional(),
|
estimate_number: z.string().optional(),
|
||||||
date: z.string().optional(),
|
date: z.string().optional(),
|
||||||
has_insurance: z.boolean().default(false),
|
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
|
labels: z
|
||||||
.array(z.object({ value: z.string(), label: z.string() }))
|
.array(
|
||||||
.default([]),
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
color_code: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type EstimateFormValues = z.infer<typeof estimateFormSchema>
|
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: "",
|
description: "",
|
||||||
selling_price: undefined,
|
selling_price: undefined,
|
||||||
purchase_price: undefined,
|
purchase_price: undefined,
|
||||||
|
opening_stock: undefined,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapping helpers ──
|
// ── Mapping helpers ──
|
||||||
@ -68,6 +70,7 @@ function mapToFormValues(data: unknown): PartFormValues {
|
|||||||
description: d.description ?? "",
|
description: d.description ?? "",
|
||||||
selling_price: d.selling_price ?? undefined,
|
selling_price: d.selling_price ?? undefined,
|
||||||
purchase_price: d.purchase_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,
|
description: values.description || undefined,
|
||||||
selling_price: values.selling_price,
|
selling_price: values.selling_price,
|
||||||
purchase_price: values.purchase_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>
|
</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
|
<RhfTextareaField
|
||||||
name="description"
|
name="description"
|
||||||
label="Description"
|
label="Description"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export const partFormSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
selling_price: z.coerce.number().min(0).optional(),
|
selling_price: z.coerce.number().min(0).optional(),
|
||||||
purchase_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>
|
export type PartFormValues = z.infer<typeof partFormSchema>
|
||||||
|
|||||||
71
apps/dashboard/modules/parts/parts-columns.tsx
Normal file
71
apps/dashboard/modules/parts/parts-columns.tsx
Normal file
@ -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>>
|
||||||
142
apps/dashboard/modules/parts/parts-selector-field.tsx
Normal file
142
apps/dashboard/modules/parts/parts-selector-field.tsx
Normal file
@ -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,
|
RhfAsyncSelectField,
|
||||||
RhfDateField,
|
RhfDateField,
|
||||||
} from "@/shared/components/form"
|
} from "@/shared/components/form"
|
||||||
|
import { PartsSelectorField } from "@/modules/parts/parts-selector-field"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||||
@ -37,12 +38,14 @@ export type PurchaseOrderFormProps = {
|
|||||||
|
|
||||||
const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
const DEFAULT_VALUES: PurchaseOrderFormValues = {
|
||||||
vendor: null,
|
vendor: null,
|
||||||
|
order_number: "" ,
|
||||||
job_card: null,
|
job_card: null,
|
||||||
department: null,
|
department: null,
|
||||||
title: "",
|
title: "",
|
||||||
order_date: new Date().toISOString().split("T")[0],
|
order_date: new Date().toISOString().split("T")[0],
|
||||||
delivery_date: "",
|
delivery_date: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
items: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapping helpers ──
|
// ── Mapping helpers ──
|
||||||
@ -57,7 +60,15 @@ function mapToFormValues(data: unknown): PurchaseOrderFormValues {
|
|||||||
title: d.title || "",
|
title: d.title || "",
|
||||||
order_date: d.order_date || "",
|
order_date: d.order_date || "",
|
||||||
delivery_date: d.delivery_date || "",
|
delivery_date: d.delivery_date || "",
|
||||||
|
order_number: d.order_number || "" as any,
|
||||||
notes: d.notes || "",
|
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),
|
department_id: toId(values.department),
|
||||||
title: values.title,
|
title: values.title,
|
||||||
order_date: values.order_date || undefined,
|
order_date: values.order_date || undefined,
|
||||||
|
order_number: values.order_number,
|
||||||
delivery_date: values.delivery_date || undefined,
|
delivery_date: values.delivery_date || undefined,
|
||||||
notes: values.notes || 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>
|
<FieldGroup>
|
||||||
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<RhfTextField name="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="order_date" label="Order Date" />
|
||||||
<RhfDateField name="delivery_date" label="Delivery Date" />
|
<RhfDateField name="delivery_date" label="Delivery Date" />
|
||||||
</div>
|
</div>
|
||||||
@ -170,6 +189,8 @@ export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: Purcha
|
|||||||
|
|
||||||
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
<RhfTextareaField name="notes" label="Notes" rows={3} />
|
||||||
|
|
||||||
|
<PartsSelectorField<PurchaseOrderFormValues, "items"> name="items" />
|
||||||
|
|
||||||
<Button type="submit" variant="default" disabled={isPending}>
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
"Saving..."
|
"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() })
|
.object({ value: z.string(), label: z.string() })
|
||||||
.nullable()
|
.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({
|
const purchaseOrderFormSchema = z.object({
|
||||||
// ── Relations ──
|
// ── Relations ──
|
||||||
vendor: relationFieldSchema,
|
vendor: relationFieldSchema,
|
||||||
@ -15,9 +23,15 @@ const purchaseOrderFormSchema = z.object({
|
|||||||
order_date: z.string().optional(),
|
order_date: z.string().optional(),
|
||||||
delivery_date: z.string().optional(),
|
delivery_date: z.string().optional(),
|
||||||
notes: 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 PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
|
||||||
|
type PurchaseOrderItem = z.infer<typeof purchaseOrderItemSchema>
|
||||||
|
|
||||||
export { purchaseOrderFormSchema, relationFieldSchema }
|
export { purchaseOrderFormSchema, purchaseOrderItemSchema, relationFieldSchema }
|
||||||
export type { PurchaseOrderFormValues }
|
export type { PurchaseOrderFormValues, PurchaseOrderItem }
|
||||||
|
|
||||||
|
|||||||
37
apps/dashboard/modules/services/services-columns.tsx
Normal file
37
apps/dashboard/modules/services/services-columns.tsx
Normal file
@ -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>>
|
||||||
140
apps/dashboard/modules/services/services-selector-field.tsx
Normal file
140
apps/dashboard/modules/services/services-selector-field.tsx
Normal file
@ -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 { useRef, useState } from "react"
|
||||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Car, Loader2 } from "lucide-react"
|
import { Car, Loader2, PlusIcon } from "lucide-react"
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
import { useAuthApi } from "@/shared/useApi"
|
||||||
import { VEHICLE_ROUTES } from "@garage/api"
|
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 {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxInput,
|
ComboboxInput,
|
||||||
@ -15,8 +18,8 @@ import {
|
|||||||
ComboboxItem,
|
ComboboxItem,
|
||||||
ComboboxEmpty,
|
ComboboxEmpty,
|
||||||
} from "@/shared/components/ui/combobox"
|
} from "@/shared/components/ui/combobox"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { VehicleForm } from "./vehicle-form"
|
||||||
|
|
||||||
// ── Vehicle option type (enriched for display) ──
|
// ── Vehicle option type (enriched for display) ──
|
||||||
|
|
||||||
@ -87,6 +90,8 @@ export function RhfVehicleSelectField<
|
|||||||
const api = useAuthApi()
|
const api = useAuthApi()
|
||||||
const anchorRef = useRef<HTMLDivElement>(null)
|
const anchorRef = useRef<HTMLDivElement>(null)
|
||||||
const [inputValue, setInputValue] = useState("")
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { control } = useFormContext<TValues>()
|
const { control } = useFormContext<TValues>()
|
||||||
const {
|
const {
|
||||||
@ -113,14 +118,17 @@ export function RhfVehicleSelectField<
|
|||||||
)
|
)
|
||||||
: options
|
: options
|
||||||
|
|
||||||
return (
|
const handleCreateSuccess = (data?: any) => {
|
||||||
<FieldShell
|
const item = data?.data ?? data
|
||||||
label={label}
|
if (item?.id) {
|
||||||
error={error?.message}
|
field.onChange(buildVehicleOption(item))
|
||||||
description={description}
|
}
|
||||||
required={required}
|
queryClient.invalidateQueries({ queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"] })
|
||||||
>
|
setIsCreateOpen(false)
|
||||||
<div ref={anchorRef}>
|
}
|
||||||
|
|
||||||
|
const combobox = (
|
||||||
|
<div ref={anchorRef}>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
|
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
|
||||||
@ -204,6 +212,44 @@ export function RhfVehicleSelectField<
|
|||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</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 = {
|
export type VehicleFormProps = {
|
||||||
resourceId?: string | null
|
resourceId?: string | null
|
||||||
initialData?: unknown
|
initialData?: unknown
|
||||||
onSuccess?: () => void
|
onSuccess?: (data?: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default values ──
|
// ── Default values ──
|
||||||
@ -138,9 +138,9 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
|||||||
})
|
})
|
||||||
return promise
|
return promise
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
form.reset()
|
form.reset()
|
||||||
onSuccess?.()
|
onSuccess?.(data)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query"
|
|||||||
import { RefreshCw } from "lucide-react"
|
import { RefreshCw } from "lucide-react"
|
||||||
|
|
||||||
import { useAuthApi } from "@/shared/useApi"
|
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 { FieldShell } from "../field-shell"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@ -21,7 +21,7 @@ type RhfAutoGenerateFieldProps<
|
|||||||
required?: boolean
|
required?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
table: string
|
table: Tables
|
||||||
/** When true, fetches the next code immediately on mount */
|
/** When true, fetches the next code immediately on mount */
|
||||||
autoFetch?: boolean
|
autoFetch?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import type { FieldValues } from "react-hook-form"
|
import type { FieldValues } from "react-hook-form"
|
||||||
import { FormProvider } from "react-hook-form"
|
import { FormProvider } from "react-hook-form"
|
||||||
import type { RhformProps } from "./types"
|
import type { RhformProps } from "./types"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
export function Rhform<TValues extends FieldValues>({
|
export function Rhform<TValues extends FieldValues>({
|
||||||
form,
|
form,
|
||||||
@ -15,7 +16,7 @@ export function Rhform<TValues extends FieldValues>({
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
|
onSubmit={(e) => { e.stopPropagation(); form.handleSubmit(onSubmit)(e) }}
|
||||||
noValidate
|
noValidate
|
||||||
className={className}
|
className={cn('p-1',className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</form>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import React from "react"
|
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 { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
|
||||||
import type { ColumnDef } from "@tanstack/react-table"
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
|
||||||
@ -28,10 +28,13 @@ type ReactNodeOrRender<TClient extends ResourcePageClient> =
|
|||||||
| React.ReactNode
|
| React.ReactNode
|
||||||
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
|
| ((context: CrudResourceContext<TClient>) => React.ReactNode)
|
||||||
|
|
||||||
|
type ManagedTableProps = "columns" | "data" | "pagination" | "sorting" | "onChange" | "isLoading"
|
||||||
|
|
||||||
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
||||||
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: CrudResourceColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||||
onRowClick?: (row: ResourceItem<TClient>) => void
|
onRowClick?: (row: ResourceItem<TClient>) => void
|
||||||
tableHeader?: ReactNodeOrRender<TClient>
|
tableHeader?: ReactNodeOrRender<TClient>
|
||||||
|
tableProps?: Omit<Partial<DataViewProps<ResourceItem<TClient>>>, ManagedTableProps>
|
||||||
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
|
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,6 +47,7 @@ export function CrudResource<TClient extends ResourcePageClient>({
|
|||||||
extraParams,
|
extraParams,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
tableHeader,
|
tableHeader,
|
||||||
|
tableProps,
|
||||||
render,
|
render,
|
||||||
}: CrudResourceProps<TClient>) {
|
}: CrudResourceProps<TClient>) {
|
||||||
type TItem = ResourceItem<TClient>
|
type TItem = ResourceItem<TClient>
|
||||||
@ -79,13 +83,14 @@ export function CrudResource<TClient extends ResourcePageClient>({
|
|||||||
<>
|
<>
|
||||||
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
|
||||||
<DataTable
|
<DataTable
|
||||||
|
{...tableProps}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
pagination={page.pagination}
|
pagination={page.pagination}
|
||||||
sorting={page.sorting}
|
sorting={page.sorting}
|
||||||
onChange={page.handleChange}
|
onChange={page.handleChange}
|
||||||
isLoading={page.isLoading}
|
isLoading={page.isLoading}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick ?? tableProps?.onRowClick}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect, useRef } from "react"
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
type RowSelectionState,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -15,8 +17,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { DataViewProvider } from "./data-view-context"
|
import { DataViewProvider } from "./data-view-context"
|
||||||
import type { DataViewProps } from "./types"
|
import type { DataViewProps } from "./types"
|
||||||
import { DataViewPagination } from "./data-view-pagination"
|
import { DataViewPagination } from "./data-view-pagination"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
@ -29,20 +32,83 @@ export function DataTable<TData>({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
slots,
|
slots,
|
||||||
|
selection,
|
||||||
}: DataViewProps<TData>) {
|
}: 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({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns: columns as ColumnDef<TData, unknown>[],
|
columns: resolvedColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
manualSorting: true,
|
manualSorting: true,
|
||||||
pageCount: pagination.pageCount,
|
pageCount: pagination.pageCount,
|
||||||
|
enableRowSelection: !!selection,
|
||||||
|
getRowId: selection
|
||||||
|
? (row) => String((row as Record<string, unknown>)[(selection.rowKey as string) ?? "id"])
|
||||||
|
: undefined,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
pagination: {
|
pagination: {
|
||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
},
|
},
|
||||||
|
rowSelection,
|
||||||
},
|
},
|
||||||
onSortingChange: (updater) => {
|
onSortingChange: (updater) => {
|
||||||
const next = typeof updater === "function" ? updater(sorting) : 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 (
|
return (
|
||||||
|
|||||||
@ -26,6 +26,12 @@ export type DataViewSlots = {
|
|||||||
footer?: ReactNode
|
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> = {
|
export type DataViewProps<TData> = {
|
||||||
columns: ColumnDef<TData, any>[]
|
columns: ColumnDef<TData, any>[]
|
||||||
data: TData[]
|
data: TData[]
|
||||||
@ -35,4 +41,5 @@ export type DataViewProps<TData> = {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onRowClick?: (row: TData) => void
|
onRowClick?: (row: TData) => void
|
||||||
slots?: DataViewSlots
|
slots?: DataViewSlots
|
||||||
|
selection?: DataViewSelection<TData>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,24 @@
|
|||||||
# Garage Management System — Feature Implementation Checklist
|
# Garage Management System — Feature Implementation Checklist
|
||||||
|
|
||||||
> **Generated**: April 3, 2026
|
> **Generated**: April 11, 2026
|
||||||
> **Dependency Order Reference**: Postman API Collection (`packages/api/postman/collection.json`)
|
> **Dependency Order Reference**: Postman API Collection (`packages/api/postman/collection.json`)
|
||||||
> **Status Source of Truth**: Implemented API clients in `packages/api/src/clients` and implemented dashboard modules in `apps/dashboard/modules`
|
> **Status Source of Truth**: API clients in `packages/api/src/clients`, route pages in `apps/dashboard/app`, and dashboard modules in `apps/dashboard/modules`
|
||||||
> **Ordered by**: Dependency level (no dependencies → most complex relations)
|
> **Ordered by**: Dependency level (no dependencies → most complex relations)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Read This Checklist
|
## How to Read This Checklist
|
||||||
|
|
||||||
- **✅ Client + UI Module** = matching API client exists and a matching dashboard module exists
|
- **✅ Client + UI** = a matching API client exists and at least one dedicated route page or module exists
|
||||||
- **🔧 API Client Only** = matching API client exists, but no dedicated dashboard module was found
|
- **🔧 Client Only** = a matching API client exists, but no dedicated route page or module was found
|
||||||
- **🧩 UI Module Only** = matching dashboard module exists, but no matching API client was found
|
- **🧩 UI Only** = a dedicated route page or module exists, but no matching API client was found
|
||||||
- **⬜ Not Started** = no matching client or module was found
|
- **⬜ Not Started** = no client, page, or module was found
|
||||||
- **Module** = a dedicated top-level form, inline form, or resource subform inside `apps/dashboard/modules`
|
- **Client** paths are relative to `packages/api/src/clients`
|
||||||
- **Lookup usage only** (for example an async select inside another form) does **not** count as a module
|
- **Page** paths are relative to `apps/dashboard/app`
|
||||||
|
- **Module** paths are relative to `apps/dashboard/modules`
|
||||||
|
- **Module** includes top-level forms, CRUD dialogs, detail subforms, and inline forms
|
||||||
|
- **Lookup usage only** (for example an async select or picker field) does **not** count as module coverage
|
||||||
|
- **Page coverage** only counts when that route actively manages the resource itself; a parent route that happens to exist for a neighboring resource does not count
|
||||||
- **Depends on** = other resources that must exist before this one (based on foreign keys in the Postman collection)
|
- **Depends on** = other resources that must exist before this one (based on foreign keys in the Postman collection)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -23,39 +27,39 @@
|
|||||||
|
|
||||||
These resources have no foreign key references. They are the foundation.
|
These resources have no foreign key references. They are the foundation.
|
||||||
|
|
||||||
| # | Resource | Status | Implementation Details |
|
| # | Resource | Status | Client | Page | Module |
|
||||||
|---|----------|--------|----------------------|
|
|---|----------|--------|--------|------|--------|
|
||||||
| 1 | Auth (Login / Profile / Logout) | ✅ Client + UI Module | Client: `AuthClient` · Module: `auth/login-form.tsx` |
|
| 1 | Auth (Login / Profile / Logout) | ✅ Client + UI | `auth.ts` | `(auth)/login/page.tsx` | `auth/login-form.tsx` |
|
||||||
| 2 | Countries | 🔧 API Client Only | Client: `GeoClient.countries()` · Module: none |
|
| 2 | Countries | 🔧 Client Only | `geo.ts` | — | — |
|
||||||
| 3 | Customer Types | 🔧 API Client Only | Client: `CustomersClient.listCustomerTypes()` · Module: none |
|
| 3 | Customer Types | 🔧 Client Only | `customers.ts` | — | — |
|
||||||
| 4 | Referral Sources | 🔧 API Client Only | Client: `ReferralSourcesClient` · Module: none |
|
| 4 | Referral Sources | 🔧 Client Only | `referral-sources.ts` | — | — |
|
||||||
| 5 | Payment Terms | 🔧 API Client Only | Client: `PaymentTermsClient` · Module: none |
|
| 5 | Payment Terms | 🔧 Client Only | `payment-terms.ts` | — | — |
|
||||||
| 6 | Payment Modes | 🔧 API Client Only | Client: `PaymentsClient.listModes()` · Module: none |
|
| 6 | Payment Modes | 🔧 Client Only | `payment-modes.ts` | — | — |
|
||||||
| 7 | Shop Types | ✅ Client + UI Module | Client: `ShopTypesClient` · Module: `settings/shop-type/shop-type-form.tsx` |
|
| 7 | Shop Types | ✅ Client + UI | `shop-types.ts` | `(authenticated)/settings/shop-type/page.tsx` | `settings/shop-type/shop-type-form.tsx` |
|
||||||
| 8 | Vehicle Body Types | ✅ Client + UI Module | Client: `VehicleAttributesClient.listBodyTypes()` · Module: `vehicles/inline-forms/body-type-inline-form.tsx` |
|
| 8 | Vehicle Body Types | ✅ Client + UI | `vehicle-attributes.ts` | — | `vehicles/inline-forms/body-type-inline-form.tsx` |
|
||||||
| 9 | Vehicle Fuel Types | ✅ Client + UI Module | Client: `VehicleAttributesClient.listFuelTypes()` · Module: `vehicles/inline-forms/fuel-type-inline-form.tsx` |
|
| 9 | Vehicle Fuel Types | ✅ Client + UI | `vehicle-attributes.ts` | — | `vehicles/inline-forms/fuel-type-inline-form.tsx` |
|
||||||
| 10 | Vehicle Transmissions | ✅ Client + UI Module | Client: `VehicleAttributesClient.listTransmissions()` · Module: `vehicles/inline-forms/transmission-inline-form.tsx` |
|
| 10 | Vehicle Transmissions | ✅ Client + UI | `vehicle-attributes.ts` | — | `vehicles/inline-forms/transmission-inline-form.tsx` |
|
||||||
| 11 | Vehicle Colors | ✅ Client + UI Module | Client: `VehicleAttributesClient.listColors()` · Module: `vehicles/inline-forms/color-inline-form.tsx` |
|
| 11 | Vehicle Colors | ✅ Client + UI | `vehicle-attributes.ts` | — | `vehicles/inline-forms/color-inline-form.tsx` |
|
||||||
| 12 | Document Types | ✅ Client + UI Module | Client: `VehicleDocumentsClient.listDocumentTypes()` · Module: `vehicles/inline-forms/document-type-inline-form.tsx` |
|
| 12 | Document Types | ✅ Client + UI | `vehicle-documents.ts` | — | `vehicles/inline-forms/document-type-inline-form.tsx` |
|
||||||
| 13 | Unit Types | ✅ Client + UI Module | Client: `InventoryClient.listUnitTypes()` · Module: `services/inline-forms/unit-type-inline-form.tsx` |
|
| 13 | Unit Types | ✅ Client + UI | `inventory.ts` | — | `services/inline-forms/unit-type-inline-form.tsx` |
|
||||||
| 14 | Labels | 🔧 API Client Only | Client: `LabelsClient` · Module: none |
|
| 14 | Labels | 🔧 Client Only | `labels.ts` | — | — |
|
||||||
| 15 | Insurance Types | 🔧 API Client Only | Client: `InsuranceTypesClient` · Module: none |
|
| 15 | Insurance Types | ✅ Client + UI | `insurance-types.ts` | `(authenticated)/settings/insurance-types/page.tsx` | `settings/insurance-types/insurance-type-form.tsx` |
|
||||||
| 16 | Inspection Categories | ✅ Client + UI Module | Client: `InspectionsClient.listCategories()` · Module: `inspections/inline-forms/inspection-category-inline-form.tsx` |
|
| 16 | Inspection Categories | ✅ Client + UI | `inspections.ts` | — | `inspections/inline-forms/inspection-category-inline-form.tsx` |
|
||||||
| 17 | Check Point Labels | 🔧 API Client Only | Client: `InspectionsClient.listCheckpointLabels()` · Module: none |
|
| 17 | Check Point Labels | 🔧 Client Only | `inspections.ts` | — | — |
|
||||||
| 18 | Quick Remarks | 🔧 API Client Only | Client: `EstimatesClient.listQuickRemarks()` · Module: none |
|
| 18 | Quick Remarks | 🔧 Client Only | `quick-remarks.ts` | — | — |
|
||||||
| 19 | Quick Notes | 🔧 API Client Only | Client: `EstimatesClient.listQuickNotes()` · Module: none |
|
| 19 | Quick Notes | 🔧 Client Only | `quick-notes.ts` | — | — |
|
||||||
| 20 | Reasons | ⬜ Not Started | Client: none found · Module: none |
|
| 20 | Reasons | 🔧 Client Only | `reasons.ts` | — | — |
|
||||||
| 21 | Task Types | 🔧 API Client Only | Client: `TasksClient.listTypes()` · Module: none |
|
| 21 | Task Types | ✅ Client + UI | `task-types.ts` | — | `tasks/task-type-form.tsx`, `tasks/task-type-crud-dialog.tsx` |
|
||||||
| 22 | Task Sections | 🔧 API Client Only | Client: `TasksClient.listSections()` · Module: none |
|
| 22 | Task Sections | ✅ Client + UI | `task-sections.ts` | — | `tasks/task-section-form.tsx`, `tasks/task-section-crud-dialog.tsx` |
|
||||||
| 23 | Invoice Labels | 🔧 API Client Only | Client: `InvoicesClient.listLabels()` · Module: none |
|
| 23 | Invoice Labels | 🔧 Client Only | `invoices.ts` | — | — |
|
||||||
| 24 | Holiday Years | ✅ Client + UI Module | Client: `HolidayYearsClient` · Module: `settings/holiday-year/holiday-year-form.tsx` |
|
| 24 | Holiday Years | ✅ Client + UI | `holiday-years.ts` | `(authenticated)/productivity/holidays/page.tsx` | `settings/holiday-year/holiday-year-form.tsx` |
|
||||||
| 25 | Taxes | ✅ Client + UI Module | Client: `TaxesClient` · Module: `settings/tax-rates/tax-form.tsx` |
|
| 25 | Taxes | ✅ Client + UI | `taxes.ts` | `(authenticated)/settings/tax-rates/page.tsx` | `settings/tax-rates/tax-form.tsx` |
|
||||||
| 26 | Departments | ✅ Client + UI Module | Client: `DepartmentsClient` · Module: `services/inline-forms/department-inline-form.tsx` |
|
| 26 | Departments | ✅ Client + UI | `departments.ts` | `(authenticated)/settings/departments/page.tsx` | `settings/departments/department-form.tsx` |
|
||||||
| 27 | Labor Rates | 🔧 API Client Only | Client: `InventoryClient.listLaborRates()` · Module: none |
|
| 27 | Labor Rates | 🔧 Client Only | `inventory.ts` | — | — |
|
||||||
| 28 | Vendors | ✅ Client + UI Module | Client: `VendorsClient` · Module: `vendors/vendor-form.tsx` |
|
| 28 | Vendors | ✅ Client + UI | `vendors.ts` | `(authenticated)/purchase/vendor/page.tsx` | `vendors/vendor-form.tsx` |
|
||||||
| 29 | Shop Calendars | ✅ Client + UI Module | Client: `ShopCalendarsClient` · Module: `shop-calendars/shop-calendar-form.tsx` |
|
| 29 | Shop Calendars | ✅ Client + UI | `shop-calendars.ts` | `(authenticated)/productivity/shop-calendars/page.tsx` | `shop-calendars/shop-calendar-form.tsx` |
|
||||||
| 30 | Shop Timings | ✅ Client + UI Module | Client: `ShopTimingsClient` · Module: `shop-timings/shop-timing-form.tsx` |
|
| 30 | Shop Timings | ✅ Client + UI | `shop-timings.ts` | `(authenticated)/productivity/shop-timings/page.tsx` | `shop-timings/shop-timing-form.tsx` |
|
||||||
| 31 | Settings | ⬜ Not Started | Client: none in `packages/api/src/clients` · Module: none (tracked setting resources are listed separately) |
|
| 31 | Settings | ✅ Client + UI | `settings.ts`, `configurations.ts` | `(authenticated)/settings/company/page.tsx`, `(authenticated)/settings/configurations/preferences/*/page.tsx` | `settings/company/settings-form.tsx`, `settings/configurations/*-form.tsx` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -63,13 +67,13 @@ These resources have no foreign key references. They are the foundation.
|
|||||||
|
|
||||||
These depend only on Level 0 resources.
|
These depend only on Level 0 resources.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 32 | States | 🔧 API Client Only | Countries | Client: `GeoClient.states()` · Module: none |
|
| 32 | States | 🔧 Client Only | Countries | `geo.ts` | — | — |
|
||||||
| 33 | Inventory Categories | ✅ Client + UI Module | Shop Types | Client: `InventoryClient.listCategories()` · Module: `services/inline-forms/inventory-category-inline-form.tsx` |
|
| 33 | Inventory Categories | ✅ Client + UI | Shop Types | `inventory-categories.ts` | — | `expense-items/inventory-category-form.tsx`, `expense-items/inventory-category-crud-dialog.tsx`, `services/inline-forms/inventory-category-inline-form.tsx` |
|
||||||
| 34 | Vendor Addresses | 🔧 API Client Only | Vendors, Countries, States | Client: `VendorsClient.createAddress()` / `getAddress()` · Module: none |
|
| 34 | Vendor Addresses | 🔧 Client Only | Vendors, Countries, States | `vendors.ts` | — | — |
|
||||||
| 35 | Holidays | ⬜ Not Started | Holiday Years | Client: none dedicated · Module: none (`HolidayYearForm` covers years only) |
|
| 35 | Holidays | 🔧 Client Only | Holiday Years | `holidays.ts` | — | — |
|
||||||
| 36 | Make and Models | ⬜ Not Started | Shop Types, Body Types, Fuel Types, Transmissions | Client: none dedicated · Module: none (vehicle form uses plain make/model fields) |
|
| 36 | Make and Models | ✅ Client + UI | Shop Types, Body Types, Fuel Types, Transmissions | `make-and-models.ts` | `(authenticated)/settings/make-and-models/page.tsx` | `settings/make-and-models/make-and-model-form.tsx` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -77,11 +81,11 @@ These depend only on Level 0 resources.
|
|||||||
|
|
||||||
These depend on Level 0 + Level 1 resources and are used by many higher-level features.
|
These depend on Level 0 + Level 1 resources and are used by many higher-level features.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 37 | Customers | ✅ Client + UI Module | Customer Types, Referral Sources, Payment Terms, Countries, States | Client: `CustomersClient` · Module: `customers/customer-form.tsx` |
|
| 37 | Customers | ✅ Client + UI | Customer Types, Referral Sources, Payment Terms, Countries, States | `customers.ts` | `(authenticated)/sales/customers/page.tsx` | `customers/customer-form.tsx` |
|
||||||
| 38 | Vehicles | ✅ Client + UI Module | Shop Types, Body Types, Fuel Types, Transmissions, Colors | Client: `VehiclesClient` · Module: `vehicles/vehicle-form.tsx` |
|
| 38 | Vehicles | ✅ Client + UI | Shop Types, Body Types, Fuel Types, Transmissions, Colors | `vehicles.ts` | `(authenticated)/sales/vehicles/page.tsx` | `vehicles/vehicle-form.tsx` |
|
||||||
| 39 | Expense Items | 🔧 API Client Only | Inventory Categories, Unit Types, Departments | Client: `ExpensesClient.listItems()` / `createItem()` · Module: none |
|
| 39 | Expense Items | ✅ Client + UI | Inventory Categories, Unit Types, Departments | `expense-items.ts` | `(authenticated)/items/expense-item/page.tsx` | `expense-items/expense-item-form.tsx` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -89,15 +93,15 @@ These depend on Level 0 + Level 1 resources and are used by many higher-level fe
|
|||||||
|
|
||||||
These depend on Level 0–2 resources.
|
These depend on Level 0–2 resources.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 40 | Employees | ✅ Client + UI Module | Departments, Shop Calendars, Shop Timings | Client: `EmployeesClient` · Module: `employees/employee-form.tsx` |
|
| 40 | Employees | ✅ Client + UI | Departments, Shop Calendars, Shop Timings | `employees.ts` | `(authenticated)/productivity/employees/page.tsx` | `employees/employee-form.tsx` |
|
||||||
| 41 | Parts | ✅ Client + UI Module | Shop Types, Inventory Categories, Unit Types, Departments, Vendors | Client: `PartsClient` · Module: `parts/part-form.tsx` |
|
| 41 | Parts | ✅ Client + UI | Shop Types, Inventory Categories, Unit Types, Departments, Vendors | `parts.ts` | `(authenticated)/items/parts/page.tsx` | `parts/part-form.tsx` |
|
||||||
| 42 | Services | ✅ Client + UI Module | Shop Types, Inventory Categories, Unit Types, Departments | Client: `ServicesClient` · Module: `services/service-form.tsx` |
|
| 42 | Services | ✅ Client + UI | Shop Types, Inventory Categories, Unit Types, Departments | `services.ts` | `(authenticated)/items/services/page.tsx` | `services/service-form.tsx` |
|
||||||
| 43 | Vehicle Documents | ✅ Client + UI Module | Vehicles, Document Types | Client: `VehicleDocumentsClient.listDocuments()` / `createDocument()` · Module: `vehicles/vehicle-document-form.tsx` |
|
| 43 | Vehicle Documents | ✅ Client + UI | Vehicles, Document Types | `vehicle-documents.ts` | `(authenticated)/sales/vehicles/[id]/documents/page.tsx` | `vehicles/vehicle-document-form.tsx` |
|
||||||
| 44 | Vehicle Mileage | ✅ Client + UI Module | Vehicles | Client: `VehicleDocumentsClient.listMileage()` / `createMileage()` · Module: `vehicles/mileage-form.tsx` |
|
| 44 | Vehicle Mileage | ✅ Client + UI | Vehicles | `vehicle-documents.ts` | `(authenticated)/sales/vehicles/[id]/mileage/page.tsx` | `vehicles/mileage-form.tsx` |
|
||||||
| 45 | Time Sheets | ⬜ Not Started | Employees | Client: none found · Module: none |
|
| 45 | Time Sheets | 🔧 Client Only | Employees | `time-sheets.ts` | — | — |
|
||||||
| 46 | Invoice Sequences | ⬜ Not Started | Departments | Client: none found · Module: none |
|
| 46 | Invoice Sequences | 🔧 Client Only | Departments | `invoice-sequences.ts` | — | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -105,13 +109,13 @@ These depend on Level 0–2 resources.
|
|||||||
|
|
||||||
These depend on Level 0–3 resources.
|
These depend on Level 0–3 resources.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 47 | Service Groups | ✅ Client + UI Module | Shop Types, Inventory Categories, Unit Types, Departments | Client: `ServiceGroupsClient` · Module: `service-groups/service-group-form.tsx` |
|
| 47 | Service Groups | ✅ Client + UI | Shop Types, Inventory Categories, Unit Types, Departments | `service-groups.ts` | `(authenticated)/items/service-group/page.tsx` | `service-groups/service-group-form.tsx` |
|
||||||
| 48 | Service Group Includes | ⬜ Not Started | Service Groups | Client: none dedicated in `ServiceGroupsClient` · Module: none |
|
| 48 | Service Group Includes | 🔧 Client Only | Service Groups | `service-group-includes.ts` | — | — |
|
||||||
| 49 | Service Group Services | ⬜ Not Started | Service Groups, Services, Labor Rates, Taxes | Client: none dedicated in `ServiceGroupsClient` · Module: none |
|
| 49 | Service Group Services | 🔧 Client Only | Service Groups, Services, Labor Rates, Taxes | `service-group-services.ts` | — | — |
|
||||||
| 50 | Service Group Parts | ⬜ Not Started | Service Groups, Parts, Taxes | Client: none dedicated in `ServiceGroupsClient` · Module: none |
|
| 50 | Service Group Parts | 🔧 Client Only | Service Groups, Parts, Taxes | `service-group-parts.ts` | — | — |
|
||||||
| 51 | Service Group Pricings | ⬜ Not Started | Service Groups, Shop Types, Labor Rates, Fuel Types, Body Types | Client: none dedicated in `ServiceGroupsClient` · Module: none |
|
| 51 | Service Group Pricings | 🔧 Client Only | Service Groups, Shop Types, Labor Rates, Fuel Types, Body Types | `service-group-pricings.ts` | — | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -119,12 +123,12 @@ These depend on Level 0–3 resources.
|
|||||||
|
|
||||||
These are core garage workflow features depending on customers, vehicles, employees, etc.
|
These are core garage workflow features depending on customers, vehicles, employees, etc.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 52 | Inspections | ✅ Client + UI Module | Customers, Vehicles, Departments, Inspection Categories, Employees | Client: `InspectionsClient` · Module: `inspections/inspection-form.tsx` |
|
| 52 | Inspections | ✅ Client + UI | Customers, Vehicles, Departments, Inspection Categories, Employees | `inspections.ts` | `(authenticated)/sales/inspections/page.tsx` | `inspections/inspection-form.tsx` |
|
||||||
| 53 | Inspection Check Points | 🔧 API Client Only | Inspections, Check Point Labels | Client: `InspectionsClient.listCheckpoints()` / `createCheckpoint()` · Module: none |
|
| 53 | Inspection Check Points | ✅ Client + UI | Inspections, Check Point Labels | `inspections.ts` | `(authenticated)/sales/inspections/[id]/checkpoints/page.tsx` | — |
|
||||||
| 54 | Estimates | ✅ Client + UI Module | Customers, Vehicles, Departments, Labels | Client: `EstimatesClient` · Module: `estimates/estimate-form.tsx` |
|
| 54 | Estimates | ✅ Client + UI | Customers, Vehicles, Departments, Labels | `estimates.ts` | `(authenticated)/sales/estimates/page.tsx` | `estimates/estimate-form.tsx` |
|
||||||
| 55 | Job Cards | ✅ Client + UI Module | Customers, Vehicles, Departments, Labels, Employees | Client: `JobCardsClient` · Module: `job-cards/job-card-form.tsx` + remark/recommendation subforms |
|
| 55 | Job Cards | ✅ Client + UI | Customers, Vehicles, Departments, Labels, Employees | `job-cards.ts` | `(authenticated)/sales/job-cards/page.tsx` | `job-cards/job-card-form.tsx` + related subforms |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -132,15 +136,15 @@ These are core garage workflow features depending on customers, vehicles, employ
|
|||||||
|
|
||||||
These depend on Job Cards and other Level 5 resources.
|
These depend on Job Cards and other Level 5 resources.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 56 | Appointments | ✅ Client + UI Module | Customers, Vehicles, Departments, Job Cards, Employees, Labels | Client: `AppointmentsClient` · Module: `appointments/appointment-form.tsx` |
|
| 56 | Appointments | ✅ Client + UI | Customers, Vehicles, Departments, Job Cards, Employees, Labels | `appointments.ts` | `(authenticated)/calendar/appointment/list/page.tsx` | `appointments/appointment-form.tsx` |
|
||||||
| 57 | Tasks | 🔧 API Client Only | Task Types, Task Sections, Job Cards, Employees, Departments | Client: `TasksClient` · Module: none |
|
| 57 | Tasks | ✅ Client + UI | Task Types, Task Sections, Job Cards, Employees, Departments | `tasks.ts` | `(authenticated)/productivity/tasks/page.tsx` | `tasks/task-form.tsx` |
|
||||||
| 58 | Purchase Orders | 🔧 API Client Only | Job Cards, Vendors, Departments, Labels, Parts | Client: `PurchaseOrdersClient` · Module: none |
|
| 58 | Purchase Orders | ✅ Client + UI | Job Cards, Vendors, Departments, Labels, Parts | `purchase-orders.ts` | `(authenticated)/purchase/purchase-order/page.tsx` | `purchase-orders/purchase-order-form.tsx` |
|
||||||
| 59 | Bills | 🔧 API Client Only | Job Cards, Vendors, Vendor Addresses, Payment Terms, Departments, Labels, Parts | Client: `ExpensesClient.listBills()` / `createBill()` · Module: none |
|
| 59 | Bills | ✅ Client + UI | Job Cards, Vendors, Vendor Addresses, Payment Terms, Departments, Labels, Parts | `bills.ts` | `(authenticated)/purchase/bill/page.tsx` | `bills/bill-form.tsx` |
|
||||||
| 60 | Expenses | ✅ Client + UI Module | Job Cards, Expense Items, Vendors, Departments, Labels | Client: `ExpensesClient.listExpenses()` / `createExpense()` · Module: `expenses/expense-form.tsx` |
|
| 60 | Expenses | ✅ Client + UI | Job Cards, Expense Items, Vendors, Departments, Labels | `expenses.ts` | `(authenticated)/purchase/expense/page.tsx` | `expenses/expense-form.tsx` |
|
||||||
| 61 | Payment Received | ✅ Client + UI Module | Job Cards, Payment Modes, Customers | Client: `PaymentsClient.listReceived()` / `createReceived()` · Module: `payment-received/payment-received-form.tsx` |
|
| 61 | Payment Received | ✅ Client + UI | Job Cards, Payment Modes, Customers | `payment-received.ts` | `(authenticated)/sales/payment-received/page.tsx` | `payment-received/payment-received-form.tsx` |
|
||||||
| 62 | Inventory Adjustments | ⬜ Not Started | Parts, Job Cards, Invoices, Reasons | Client: none found · Module: none |
|
| 62 | Inventory Adjustments | ✅ Client + UI | Parts, Job Cards, Invoices, Reasons | `inventory-adjustments.ts` | `(authenticated)/items/adjustment/page.tsx` | `inventory-adjustments/inventory-adjustment-form.tsx` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -148,14 +152,14 @@ These depend on Job Cards and other Level 5 resources.
|
|||||||
|
|
||||||
These are the most complex resources with the deepest dependency chains.
|
These are the most complex resources with the deepest dependency chains.
|
||||||
|
|
||||||
| # | Resource | Status | Depends On | Implementation Details |
|
| # | Resource | Status | Depends On | Client | Page | Module |
|
||||||
|---|----------|--------|------------|----------------------|
|
|---|----------|--------|------------|--------|------|--------|
|
||||||
| 63 | Invoices | ✅ Client + UI Module | Customers, Vehicles, Departments, Invoice Sequences, Labels, Inspection Categories, Parts, Services, Expense Items, Service Groups | Client: `InvoicesClient` · Module: `invoices/invoice-form.tsx` |
|
| 63 | Invoices | ✅ Client + UI | Customers, Vehicles, Departments, Invoice Sequences, Labels, Inspection Categories, Parts, Services, Expense Items, Service Groups | `invoices.ts` | `(authenticated)/sales/invoice/page.tsx` | `invoices/invoice-form.tsx` |
|
||||||
| 64 | Invoice Documents | ✅ Client + UI Module | Invoices, Customers, Vehicles, Document Types | Client: `InvoicesClient.listDocuments()` / `createDocument()` · Module: `invoices/invoice-document-form.tsx` |
|
| 64 | Invoice Documents | ✅ Client + UI | Invoices, Customers, Vehicles, Document Types | `invoices.ts` | `(authenticated)/sales/invoice/[id]/documents/page.tsx` | `invoices/invoice-document-form.tsx` |
|
||||||
| 65 | Invoice Notes | ✅ Client + UI Module | Invoices | Client: `InvoicesClient.listNotes()` / `createNote()` · Module: `invoices/invoice-note-form.tsx` |
|
| 65 | Invoice Notes | ✅ Client + UI | Invoices | `invoices.ts` | `(authenticated)/sales/invoice/[id]/notes/page.tsx` | `invoices/invoice-note-form.tsx` |
|
||||||
| 66 | Credit Notes | ⬜ Not Started | Customers, Parts, Services, Expenses, Inspection Categories, Labels | Client: none found · Module: none |
|
| 66 | Credit Notes | ✅ Client + UI | Customers, Parts, Services, Expenses, Inspection Categories, Labels | `credit-notes.ts` | `(authenticated)/sales/credit-notes/page.tsx` | `credit-notes/credit-note-form.tsx` + document/note subforms |
|
||||||
| 67 | Payment Mades | ⬜ Not Started | Vendors, Employees, Bills, Expenses, Payment Modes | Client: none found · Module: none |
|
| 67 | Payment Mades | ✅ Client + UI | Vendors, Employees, Bills, Expenses, Payment Modes | `payment-mades.ts` | `(authenticated)/purchase/payments-made/page.tsx` | `payment-mades/payment-made-form.tsx` |
|
||||||
| 68 | Vendor Credits | ⬜ Not Started | Vendors, Departments, Parts, Services, Expenses, Labels | Client: none found · Module: none |
|
| 68 | Vendor Credits | ✅ Client + UI | Vendors, Departments, Parts, Services, Expenses, Labels | `vendor-credits.ts` | `(authenticated)/purchase/vendor-credit/page.tsx` | `vendor-credits/vendor-credit-form.tsx` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -165,52 +169,63 @@ These are the most complex resources with the deepest dependency chains.
|
|||||||
|
|
||||||
| Status | Count | Percentage |
|
| Status | Count | Percentage |
|
||||||
|--------|-------|------------|
|
|--------|-------|------------|
|
||||||
| ✅ Client + UI Module | 33 | 48.5% |
|
| ✅ Client + UI | 47 | 69.1% |
|
||||||
| 🔧 API Client Only | 21 | 30.9% |
|
| 🔧 Client Only | 21 | 30.9% |
|
||||||
| 🧩 UI Module Only | 0 | 0.0% |
|
| 🧩 UI Only | 0 | 0.0% |
|
||||||
| ⬜ Not Started | 14 | 20.6% |
|
| ⬜ Not Started | 0 | 0.0% |
|
||||||
|
|
||||||
No UI-only resources were found under the current `packages/api/src/clients` and `apps/dashboard/modules` source-of-truth rules.
|
No UI-only resources were found under the current `packages/api/src/clients`, `apps/dashboard/app`, and `apps/dashboard/modules` source-of-truth rules.
|
||||||
|
|
||||||
### Implementation Progress
|
### Implementation Progress
|
||||||
|
|
||||||
| Category | Total | ✅ Client + UI Module | 🔧 API Client Only | 🧩 UI Module Only | ⬜ Not Started |
|
| Category | Total | ✅ Client + UI | 🔧 Client Only | 🧩 UI Only | ⬜ Not Started |
|
||||||
|----------|-------|-----------------------|--------------------|------------------|----------------|
|
|----------|-------|----------------|----------------|-----------|----------------|
|
||||||
| Level 0 — Standalone | 31 | 15 | 14 | 0 | 2 |
|
| Level 0 — Standalone | 31 | 19 | 12 | 0 | 0 |
|
||||||
| Level 1 — Single Dep | 5 | 1 | 2 | 0 | 2 |
|
| Level 1 — Single Dep | 5 | 2 | 3 | 0 | 0 |
|
||||||
| Level 2 — Core Entities | 3 | 2 | 1 | 0 | 0 |
|
| Level 2 — Core Entities | 3 | 3 | 0 | 0 | 0 |
|
||||||
| Level 3 — Operational | 7 | 5 | 0 | 0 | 2 |
|
| Level 3 — Operational | 7 | 5 | 2 | 0 | 0 |
|
||||||
| Level 4 — Composite | 5 | 1 | 0 | 0 | 4 |
|
| Level 4 — Composite | 5 | 1 | 4 | 0 | 0 |
|
||||||
| Level 5 — Workflows | 4 | 3 | 1 | 0 | 0 |
|
| Level 5 — Workflows | 4 | 4 | 0 | 0 | 0 |
|
||||||
| Level 6 — Financial | 7 | 3 | 3 | 0 | 1 |
|
| Level 6 — Financial | 7 | 7 | 0 | 0 | 0 |
|
||||||
| Level 7 — Invoicing | 6 | 3 | 0 | 0 | 3 |
|
| Level 7 — Invoicing | 6 | 6 | 0 | 0 | 0 |
|
||||||
| **Total** | **68** | **33** | **21** | **0** | **14** |
|
| **Total** | **68** | **47** | **21** | **0** | **0** |
|
||||||
|
|
||||||
### Resources with Client + UI Module (33 total)
|
### UI Coverage Shape
|
||||||
|
|
||||||
- Top-level modules (20): Auth, Shop Types, Holiday Years, Taxes, Vendors, Shop Calendars, Shop Timings, Customers, Vehicles, Employees, Parts, Services, Service Groups, Inspections, Estimates, Job Cards, Appointments, Expenses, Payment Received, Invoices
|
| Coverage Shape | Count |
|
||||||
- Inline modules (9): Vehicle Body Types, Vehicle Fuel Types, Vehicle Transmissions, Vehicle Colors, Document Types, Unit Types, Inspection Categories, Departments, Inventory Categories
|
|----------------|-------|
|
||||||
- Detail subforms (4): Vehicle Documents, Vehicle Mileage, Invoice Documents, Invoice Notes
|
| Client + Page + Module | 36 |
|
||||||
|
| Client + Module Only | 10 |
|
||||||
|
| Client + Page Only | 1 |
|
||||||
|
|
||||||
### API Clients Without UI Modules (21 total)
|
The single page-only resource is **Inspection Check Points**, which is implemented directly in its route page without a dedicated module file.
|
||||||
|
|
||||||
- Master data and reference resources: Countries, Customer Types, Referral Sources, Payment Terms, Payment Modes, Labels, Insurance Types, Check Point Labels, Quick Remarks, Quick Notes, Task Types, Task Sections, Invoice Labels, Labor Rates, States, Vendor Addresses
|
### Status Changes Since April 3, 2026
|
||||||
- Workflow and transactional resources: Expense Items, Inspection Check Points, Tasks, Purchase Orders, Bills
|
|
||||||
|
|
||||||
### Not Started (14 total)
|
- Moved to **✅ Client + UI**: Insurance Types, Task Types, Task Sections, Settings, Make and Models, Expense Items, Inspection Check Points, Tasks, Purchase Orders, Bills, Inventory Adjustments, Credit Notes, Payment Mades, Vendor Credits
|
||||||
|
- Moved to **🔧 Client Only** from **⬜ Not Started**: Reasons, Holidays, Time Sheets, Invoice Sequences, Service Group Includes, Service Group Services, Service Group Parts, Service Group Pricings
|
||||||
|
|
||||||
- Reasons, Settings, Holidays, Make and Models, Time Sheets, Invoice Sequences, Service Group Includes, Service Group Services, Service Group Parts, Service Group Pricings, Inventory Adjustments, Credit Notes, Payment Mades, Vendor Credits
|
### Resources with Client + UI Coverage (47 total)
|
||||||
|
|
||||||
### API Clients Without UI Modules — Priority Recommendations
|
- Page + module coverage (36): Auth, Shop Types, Insurance Types, Holiday Years, Taxes, Departments, Vendors, Shop Calendars, Shop Timings, Settings, Make and Models, Customers, Vehicles, Expense Items, Employees, Parts, Services, Vehicle Documents, Vehicle Mileage, Service Groups, Inspections, Estimates, Job Cards, Appointments, Tasks, Purchase Orders, Bills, Expenses, Payment Received, Inventory Adjustments, Invoices, Invoice Documents, Invoice Notes, Credit Notes, Payment Mades, Vendor Credits
|
||||||
|
- Module-only coverage (10): Vehicle Body Types, Vehicle Fuel Types, Vehicle Transmissions, Vehicle Colors, Document Types, Unit Types, Inspection Categories, Task Types, Task Sections, Inventory Categories
|
||||||
|
- Page-only coverage (1): Inspection Check Points
|
||||||
|
|
||||||
Based on current implementation depth and operational value, the highest-leverage missing UI modules are:
|
### API Clients Without UI Coverage (21 total)
|
||||||
|
|
||||||
1. **Purchase Orders** — existing client, central purchasing workflow
|
- Master data and reference resources: Countries, Customer Types, Referral Sources, Payment Terms, Payment Modes, Labels, Check Point Labels, Quick Remarks, Quick Notes, Reasons, Invoice Labels, Labor Rates, States
|
||||||
2. **Bills** — existing client, complements Vendors and Expenses
|
- Operational and nested resources: Vendor Addresses, Holidays, Time Sheets, Invoice Sequences, Service Group Includes, Service Group Services, Service Group Parts, Service Group Pricings
|
||||||
3. **Tasks** — existing client, operational workflow layer is in place but no module exists
|
|
||||||
4. **Expense Items** — existing client, currently blocks full expense master-data management
|
### API Clients Without UI Coverage — Priority Recommendations
|
||||||
5. **Payment Modes / Payment Terms** — existing clients, important finance reference data
|
|
||||||
6. **Referral Sources / Customer Types** — existing clients, useful master-data UI for sales flows
|
Based on current implementation depth and dependency weight, the highest-leverage missing UI coverage is:
|
||||||
|
|
||||||
|
1. **Service Group Includes / Services / Parts / Pricings** — the client layer exists, but the service-group composition workflow is still missing from the dashboard
|
||||||
|
2. **Invoice Sequences** — invoices are implemented, but their setup dependency still has no route or module
|
||||||
|
3. **Holidays** — the current `productivity/holidays` route manages Holiday Years, not Holiday entries
|
||||||
|
4. **Payment Modes / Payment Terms** — still important finance reference data with no dedicated UI
|
||||||
|
5. **Referral Sources / Customer Types / Reasons / Labels** — useful master-data UI for sales and workflow flows
|
||||||
|
6. **Time Sheets / Vendor Addresses** — operational subresources still have API support only
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
147
docs/dashboard/phase-1-comparison.md
Normal file
147
docs/dashboard/phase-1-comparison.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# Phase 1 Comparison
|
||||||
|
|
||||||
|
Date: 2026-04-11
|
||||||
|
|
||||||
|
This document compares the current dashboard implementation against Phase 1, Garage Operations (Sellable MVP).
|
||||||
|
|
||||||
|
## How This Was Evaluated
|
||||||
|
|
||||||
|
- Source of truth for resource coverage: `docs/dashboard/feature-checklist.md`
|
||||||
|
- Validation method: direct inspection of current pages, modules, and API clients
|
||||||
|
- `Ready` means the feature is clearly exposed in the current UI and backed by implementation
|
||||||
|
- `Missing or Partial` means the workflow is absent, incomplete, or not clearly surfaced in the current UI
|
||||||
|
|
||||||
|
## Ready Features
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Operational dashboard overview is present
|
||||||
|
- Work orders by status are present
|
||||||
|
- Today or upcoming appointments are present
|
||||||
|
- Revenue and financial snapshot widgets are present
|
||||||
|
- Customer, vehicle, item, and sales or purchase summary cards are present
|
||||||
|
|
||||||
|
### Customers and Vehicles
|
||||||
|
|
||||||
|
- Customer management is implemented
|
||||||
|
- Customer notes are implemented
|
||||||
|
- Customer vehicle linking is implemented
|
||||||
|
- Vehicle profile management is implemented
|
||||||
|
- Vehicle mileage tracking is implemented
|
||||||
|
- Vehicle documents are implemented
|
||||||
|
|
||||||
|
### Services, Parts, and Inventory Basics
|
||||||
|
|
||||||
|
- Service catalog is implemented
|
||||||
|
- Service groups are implemented
|
||||||
|
- Service pricing is implemented
|
||||||
|
- Parts catalog is implemented
|
||||||
|
- SKU support is implemented for parts
|
||||||
|
- Inventory adjustments are implemented
|
||||||
|
|
||||||
|
### Suppliers and Purchasing Basics
|
||||||
|
|
||||||
|
- Vendor management is implemented
|
||||||
|
- Purchase orders are implemented
|
||||||
|
- Bills are implemented
|
||||||
|
- Vendor credits are implemented
|
||||||
|
- Payments made are implemented
|
||||||
|
|
||||||
|
### Appointments and Inspections
|
||||||
|
|
||||||
|
- Appointment list and detail flow is implemented
|
||||||
|
- Appointment status handling is implemented
|
||||||
|
- Technician assignment on appointments is implemented
|
||||||
|
- Inspection creation and detail flow is implemented
|
||||||
|
- Inspection checkpoints are implemented
|
||||||
|
- Inspection notes are implemented
|
||||||
|
- Inspection photo and file attachments are implemented
|
||||||
|
|
||||||
|
### Estimates and Job Cards
|
||||||
|
|
||||||
|
- Estimate create, edit, and detail flow is implemented at a basic level
|
||||||
|
- Job card creation and detail flow is implemented
|
||||||
|
- Technician assignment on job cards is implemented
|
||||||
|
- Service writer assignment on job cards is implemented
|
||||||
|
- Job card parts flow is implemented
|
||||||
|
- Job card services flow is implemented
|
||||||
|
- Job card attachments are implemented
|
||||||
|
- Job card inspections, expenses, tasks, appointments, bills, and purchase order tabs are implemented
|
||||||
|
- Job card status progression is implemented through draft, check-in, in progress, on hold, ready to delivery, and delivered
|
||||||
|
|
||||||
|
### Employees, Permissions, and Settings
|
||||||
|
|
||||||
|
- Employee management is implemented
|
||||||
|
- Employee attendance-related fields exist
|
||||||
|
- Per-employee permissions management is implemented
|
||||||
|
- Company profile settings are implemented
|
||||||
|
- Tax settings are implemented
|
||||||
|
- Sales and purchase tax or discount preference forms are implemented
|
||||||
|
|
||||||
|
## Missing or Partial Features
|
||||||
|
|
||||||
|
### Dashboard Gaps
|
||||||
|
|
||||||
|
- Technician workload is not clearly implemented as a dashboard widget
|
||||||
|
- A dedicated active repairs workload view by technician is not clearly implemented
|
||||||
|
|
||||||
|
### Customer and Vehicle Gaps
|
||||||
|
|
||||||
|
- Fleet customer workflow is not clearly implemented
|
||||||
|
- Insurance customer workflow is not clearly implemented as a full customer segment workflow
|
||||||
|
- Customer service history view is not clearly implemented
|
||||||
|
- Vehicle service history view is not clearly implemented
|
||||||
|
- Vehicle purchase history view is not clearly implemented
|
||||||
|
|
||||||
|
### Services, Parts, and Inventory Gaps
|
||||||
|
|
||||||
|
- Service duration is not clearly implemented
|
||||||
|
- Barcode support for parts is not clearly implemented
|
||||||
|
- On-hand stock visibility is not clearly implemented in the UI
|
||||||
|
- Low-stock alerts are not clearly implemented
|
||||||
|
- Reorder alert workflow is not clearly implemented
|
||||||
|
|
||||||
|
### Supplier Gaps
|
||||||
|
|
||||||
|
- Supplier purchase history is not clearly implemented as a vendor-level detail workflow
|
||||||
|
|
||||||
|
### Appointment Gaps
|
||||||
|
|
||||||
|
- A true appointment calendar or scheduler view is not clearly implemented
|
||||||
|
- The current appointment area is list and detail oriented, not a full calendar board
|
||||||
|
|
||||||
|
### Inspection Gaps
|
||||||
|
|
||||||
|
- Inspection report generation is not clearly implemented
|
||||||
|
- Inspection print, export, or PDF output is not clearly implemented
|
||||||
|
|
||||||
|
### Estimate Gaps
|
||||||
|
|
||||||
|
- Estimate approval workflow is not clearly implemented
|
||||||
|
- Customer approval flow for estimates is not clearly implemented
|
||||||
|
- Estimate line items for services and parts are not clearly surfaced in the current detail UI
|
||||||
|
- Estimate totals or summary calculations are not clearly surfaced in the current detail UI
|
||||||
|
|
||||||
|
### Job Card Gaps
|
||||||
|
|
||||||
|
- A `Closed` status is not clearly implemented in the visible job card status flow
|
||||||
|
- Full digital authorisation workflow is only partially represented by settings fields and flags
|
||||||
|
- Parts issuing workflow appears partially represented by settings flags, but not clearly surfaced as a complete operational flow
|
||||||
|
|
||||||
|
### Employees and Access Control Gaps
|
||||||
|
|
||||||
|
- Time clock UI is not implemented
|
||||||
|
- Clock in or clock out screens are not implemented
|
||||||
|
- Technician hours tracking UI is not clearly implemented
|
||||||
|
- Named role management for Admin, Manager, Service Advisor, and Technician is not clearly implemented as a dedicated role system
|
||||||
|
|
||||||
|
### Settings Gaps
|
||||||
|
|
||||||
|
- Currency configuration is not clearly implemented
|
||||||
|
- Email system configuration is not clearly implemented beyond company email fields
|
||||||
|
|
||||||
|
## Overall Assessment
|
||||||
|
|
||||||
|
Phase 1 is not fully complete yet.
|
||||||
|
|
||||||
|
The project already covers most of the core CRUD and workshop operations foundation, especially around customers, vehicles, appointments, inspections, job cards, and purchasing resources. The largest remaining gaps are in deeper operational workflows: history views, estimate approval and totals, stock intelligence, technician time tracking, calendar-style scheduling, inspection reporting, and role-based access structure.
|
||||||
237
docs/dashboard/swagger-client-frontend-gap-report.md
Normal file
237
docs/dashboard/swagger-client-frontend-gap-report.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Phase 1 Backend and Frontend Gap Report
|
||||||
|
|
||||||
|
Date: 2026-04-11
|
||||||
|
|
||||||
|
This report answers the two deliverables requested for Phase 1:
|
||||||
|
|
||||||
|
1. Backend todos, measured against the Phase 1 roadmap using the Swagger contract in `packages/api/types/index.ts`
|
||||||
|
2. Frontend todos, measured by comparing the domain client layer in `packages/api/src/clients` and `packages/api/src/api.ts` against real dashboard usage
|
||||||
|
|
||||||
|
The frontend section still uses the contract -> client -> usage audit. The backend section has been remapped to the business roadmap instead of treating missing client wrappers as missing backend work.
|
||||||
|
|
||||||
|
## Rules Used
|
||||||
|
|
||||||
|
- Backend ready = the Phase 1 capability is clearly represented by an endpoint or response shape in `packages/api/types/index.ts`
|
||||||
|
- Backend todo = the Phase 1 workflow is missing from Swagger, or only partially represented by base CRUD without the required workflow depth
|
||||||
|
- Frontend implemented = any client method is used somewhere in dashboard source
|
||||||
|
- Shared CRUD usage counts as implementation:
|
||||||
|
- `getClient={(api) => api.foo}` on `ResourcePage` or `CrudResource` counts as `list` and `destroy`
|
||||||
|
- explicit `api.foo.bar()` calls count as direct method usage
|
||||||
|
- Generated output under `.next` was excluded
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Backend contract coverage is stronger than the earlier UI-only Phase 1 comparison suggested. The API already covers the main CRUD and operational foundation for dashboard metrics, customers, vehicles, services, parts, vendors, appointments, inspections, job cards, employees, permissions, roles, time sheets, and workshop settings.
|
||||||
|
|
||||||
|
The remaining backend work is concentrated in a smaller set of deeper workflows: history and reporting views, estimate approval and estimate line-item depth, stock intelligence such as low-stock or reorder alerts, technician-specific workload data, a visible final `closed` job-card state, and clearer system email or currency configuration.
|
||||||
|
|
||||||
|
Frontend audit counts:
|
||||||
|
|
||||||
|
- Swagger paths found: 260
|
||||||
|
- Distinct client route strings found: 232
|
||||||
|
- Swagger paths with no client wrapper: 28
|
||||||
|
- Clients exposed by `createApi()`: 57
|
||||||
|
- Clients used somewhere in dashboard source: 50
|
||||||
|
- Completely unused clients: 7
|
||||||
|
- Partially used clients: 44
|
||||||
|
- Fully wired clients under the current rule: 6
|
||||||
|
|
||||||
|
## Backend Todos
|
||||||
|
|
||||||
|
These are the Phase 1 backend todos after checking the roadmap against the actual Swagger contract.
|
||||||
|
|
||||||
|
### Already ready in backend contract
|
||||||
|
|
||||||
|
- Dashboard `/api/home` already returns financial charts, work-order status cards, appointment KPIs, upcoming appointments, inventory counts, customer segmentation counts, and vehicle breakdowns.
|
||||||
|
- Customers already support CRUD, customer types, notes, `company_name`, and insurance-related fields.
|
||||||
|
- Vehicles already support CRUD, make/model/year, VIN, plate, mileage, owner linking, documents, and mileage logs.
|
||||||
|
- Services already expose duration-related data through `labor_hours`.
|
||||||
|
- Parts and inventory already expose SKU, opening stock, and min/max stock, plus inventory-adjustment resources.
|
||||||
|
- Vendors, purchase orders, bills, vendor credits, and payment flows already exist in the contract.
|
||||||
|
- Appointments already support CRUD, status changes, and job-card unlinking.
|
||||||
|
- Inspections already support CRUD, checkpoints, labels, media, and attachments.
|
||||||
|
- Job cards already support technician and service-writer assignment, parts, services, expense items, attachments, check-in, delivery, digital-authorisation flags, and parts-issuing flags.
|
||||||
|
- Employees already support CRUD, per-employee permissions, and attendance or time-sheet related fields.
|
||||||
|
- Roles already exist in Swagger through `/api/roles` and `/api/roles/{id}`.
|
||||||
|
- Time tracking already exists in Swagger through `/api/time-sheets`, `/api/time-sheet/clock-in`, and `/api/time-sheet/clock-out`.
|
||||||
|
- Workshop settings and operational configuration already exist through `/api/settings` and `/api/configurations...`.
|
||||||
|
|
||||||
|
### Real backend todos by Phase 1 area
|
||||||
|
|
||||||
|
| Phase 1 area | Contract status | Backend todo |
|
||||||
|
|---|---|---|
|
||||||
|
| Dashboard workload | Partial | `/api/home` covers generic work-order KPIs, but there is no clear technician-workload or active-repairs-by-technician feed. |
|
||||||
|
| Customer, vehicle, and supplier history | Missing | No dedicated customer service-history, vehicle service-history, vehicle purchase-history, or vendor purchase-history routes were found. |
|
||||||
|
| Estimates | Partial | Swagger only exposes `/api/estimates` and `/api/estimates/{id}`. The `{id}` route has no `GET` in schema, and there are no approval, customer-authorisation, service-line, part-line, or totals-specific routes. |
|
||||||
|
| Inspection reporting | Missing | No inspection report, print, export, or PDF endpoints were found. |
|
||||||
|
| Inventory intelligence | Partial | Parts expose `opening_stock`, `min_stock`, and `max_stock`, but there is no barcode field and no low-stock, reorder, or alert workflow in the contract. |
|
||||||
|
| Job card final closure | Partial | Contract evidence shows `draft`, `check_in`, `in_progress`, `on_hold`, `ready_to_delivery`, and `delivered`, but no visible `closed` status. |
|
||||||
|
| System email and currency config | Partial | Settings and configuration endpoints cover workshop profile, tax or discount behavior, digital authorisation, and parts-issuing flags, but there is no clear email-delivery configuration endpoint and no clear currency-setting field even though currency is returned in dashboard responses. |
|
||||||
|
|
||||||
|
### Not backend todos anymore
|
||||||
|
|
||||||
|
These Phase 1 items are already represented in Swagger and should be treated as client or frontend work instead of backend work:
|
||||||
|
|
||||||
|
- fleet, company, and insurer customer segmentation
|
||||||
|
- service duration
|
||||||
|
- roles and permissions
|
||||||
|
- time clock, clock-in, and clock-out data layer
|
||||||
|
- digital authorisation and parts-issuing configuration flags
|
||||||
|
- appointment calendar or scheduler presentation, which looks more like a frontend presentation gap than a contract gap
|
||||||
|
|
||||||
|
### Client-layer Gaps Against Existing Swagger
|
||||||
|
|
||||||
|
These are not missing backend capabilities. They are existing Swagger routes that still do not have matching domain-client wrappers.
|
||||||
|
|
||||||
|
#### 1. Entire backend resources missing from the client layer
|
||||||
|
|
||||||
|
| Area | Missing Swagger routes | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Roles | `/api/roles`, `/api/roles/{id}` | Swagger exposes role CRUD, but there is no `RolesClient` and no `api.roles` entry. |
|
||||||
|
|
||||||
|
#### 2. Existing client families with missing route coverage
|
||||||
|
|
||||||
|
| Area | Missing Swagger routes | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Configurations | `/api/configurations` | `ConfigurationsClient` only wraps the specialized subroutes, not the aggregate base route. Review whether the base GET is required. |
|
||||||
|
| Make and Models | `/api/get-makes`, `/api/get-models`, `/api/toggle-make-and-model-status` | `MakeAndModelsClient` currently wraps only CRUD. Helper lookups and the status toggle are missing. |
|
||||||
|
| Invoice Sequences | `/api/remove-default-invoice-sequence`, `/api/set-default-invoice-sequence` | `InvoiceSequencesClient` currently wraps only CRUD. Default-sequence workflow is missing. |
|
||||||
|
|
||||||
|
#### 3. Inspection routes missing from `InspectionsClient`
|
||||||
|
|
||||||
|
- `/api/inspections/{id}/add-attachment`
|
||||||
|
- `/api/inspections/{id}/add-label`
|
||||||
|
- `/api/inspections/{id}/delete-attachment`
|
||||||
|
- `/api/inspections/{id}/delete-label`
|
||||||
|
- `/api/inspections/{id}/get-attachment`
|
||||||
|
|
||||||
|
Current `InspectionsClient` wraps checkpoint-level attachment and media helpers, but not these inspection-level attachment and label routes.
|
||||||
|
|
||||||
|
#### 4. Job card routes missing from `JobCardsClient`
|
||||||
|
|
||||||
|
- `/api/job-cards/{id}/add-expense-item`
|
||||||
|
- `/api/job-cards/{id}/add-expense-item-attachment`
|
||||||
|
- `/api/job-cards/{id}/add-internal-note`
|
||||||
|
- `/api/job-cards/{id}/add-part-attachment`
|
||||||
|
- `/api/job-cards/{id}/delete-expense-item`
|
||||||
|
- `/api/job-cards/{id}/delete-expense-item-attachment`
|
||||||
|
- `/api/job-cards/{id}/delete-internal-note`
|
||||||
|
- `/api/job-cards/{id}/delete-part-attachment`
|
||||||
|
- `/api/job-cards/{id}/edit-internal-note`
|
||||||
|
- `/api/job-cards/{id}/get-expense-item-attachment`
|
||||||
|
- `/api/job-cards/{id}/get-expense-items`
|
||||||
|
- `/api/job-cards/{id}/get-internal-notes`
|
||||||
|
- `/api/job-cards/{id}/get-part-attachment`
|
||||||
|
- `/api/job-cards/{id}/get-service-attachment`
|
||||||
|
- `/api/job-cards/{id}/update-expense-item`
|
||||||
|
|
||||||
|
Current `JobCardsClient` already covers remarks, recommendations, parts, services, and service attachments, but it does not wrap expense-item subresources, part attachments, internal notes, or attachment retrieval endpoints.
|
||||||
|
|
||||||
|
## Frontend Todos
|
||||||
|
|
||||||
|
These are client capabilities that are already available in the API layer, but are still missing or only partially wired in dashboard source.
|
||||||
|
|
||||||
|
### 1. Completely unused clients
|
||||||
|
|
||||||
|
These clients have zero usage anywhere in `apps/dashboard/app`, `apps/dashboard/modules`, or `apps/dashboard/shared`.
|
||||||
|
|
||||||
|
| Client | Gap |
|
||||||
|
|---|---|
|
||||||
|
| `holidays` | Entire client unused |
|
||||||
|
| `invoiceSequences` | Entire client unused |
|
||||||
|
| `serviceGroupIncludes` | Entire client unused, including `changeArrangement` |
|
||||||
|
| `serviceGroupParts` | Entire client unused |
|
||||||
|
| `serviceGroupPricings` | Entire client unused |
|
||||||
|
| `serviceGroupServices` | Entire client unused |
|
||||||
|
| `timeSheets` | Entire client unused, including `clockIn` and `clockOut` |
|
||||||
|
|
||||||
|
### 2. High-signal partial clients
|
||||||
|
|
||||||
|
These clients are used, but important workflows are still not wired in the frontend.
|
||||||
|
|
||||||
|
| Client | Already used | Still missing in frontend |
|
||||||
|
|---|---|---|
|
||||||
|
| `auth` | `login` | `logout`, `profile` |
|
||||||
|
| `expenses` | base CRUD plus `listItems` | bill subflow, expense-item subflow, item status toggle |
|
||||||
|
| `inspections` | base CRUD, categories, checkpoints, checkpoint media | inspection-level attachments, inspection-level labels, checkpoint label management |
|
||||||
|
| `inventory` | category and unit-type create/list, labor-rate list | labor-rate CRUD/defaults, category and unit-type update/destroy/defaulting |
|
||||||
|
| `invoices` | base CRUD, documents, notes | label CRUD/list, attachment helpers, document or note update helpers not used |
|
||||||
|
| `jobCards` | base CRUD, date/status flow, check-in, delivery, parts, services, attachments, remarks, recommendations | service-attachment UI flow, edit remark path, edit recommendation path, import/export |
|
||||||
|
| `labels` | `list`, `create` | `update`, `destroy`, `show`, import/export |
|
||||||
|
| `paymentModes` | `list` | create, update, delete, show, import/export |
|
||||||
|
| `paymentTerms` | `list` | create, update, delete, show, default-setting, import/export |
|
||||||
|
| `referralSources` | `list` | create, update, delete, show, default-setting, import/export |
|
||||||
|
| `reasons` | `list` | create, update, delete, show, import/export |
|
||||||
|
| `serviceGroups` | base CRUD | label workflows, status toggle, show, import/export |
|
||||||
|
| `shopCalendars` | `list`, `create`, `destroy` | `setDefault`, `removeDefault`, `updateDayType` |
|
||||||
|
| `tasks` | base CRUD | `complete` |
|
||||||
|
| `taskSections` | base CRUD | arrangement change, default toggles, `show`, import/export |
|
||||||
|
| `taskTypes` | base CRUD | default toggles, `show`, import/export |
|
||||||
|
| `vehicleAttributes` | create/list for body type, fuel type, transmission, color | update and destroy for all lookup records |
|
||||||
|
| `vehicleDocuments` | document and mileage flows, document-type create/list | document-type update and destroy |
|
||||||
|
| `vendors` | base CRUD | vendor address workflows, status toggle, `show`, import/export |
|
||||||
|
| `vendorCredits` | base CRUD | attachments and internal notes |
|
||||||
|
|
||||||
|
### 3. Lower-priority partials
|
||||||
|
|
||||||
|
These clients are already functionally present, but still miss mostly generic `show`, import, export, or default-toggle wiring.
|
||||||
|
|
||||||
|
- `appointments`: missing `show`, `importData`, `exportData`
|
||||||
|
- `bills`: missing `show`, `importData`, `exportData`
|
||||||
|
- `creditNotes`: missing `editInternalNote`, `importData`, `exportData`
|
||||||
|
- `customers`: the UI uses inherited `importData` and `exportData`, but the direct `import()` and `export()` alias methods are unused
|
||||||
|
- `departments`: missing `show`, `importData`, `exportData`
|
||||||
|
- `employees`: missing `importData`, `exportData`
|
||||||
|
- `estimates`: missing `show`, `importData`, `exportData`
|
||||||
|
- `expenseItems`: missing `importData`, `exportData`
|
||||||
|
- `insuranceTypes`: missing `show`, `importData`, `exportData`
|
||||||
|
- `inventoryAdjustments`: missing `show`, `importData`, `exportData`
|
||||||
|
- `inventoryCategories`: missing `show`, `importData`, `exportData`
|
||||||
|
- `makeAndModels`: missing `importData`, `exportData`
|
||||||
|
- `parts`: missing `show`, `toggleStatus`
|
||||||
|
- `paymentMades`: missing `show`, `importData`, `exportData`
|
||||||
|
- `paymentReceived`: missing `show`, `importData`, `exportData`
|
||||||
|
- `purchaseOrders`: missing `show`, `importData`, `exportData`
|
||||||
|
- `quickNotes`: missing `show`, `importData`, `exportData`
|
||||||
|
- `quickRemarks`: missing `show`, `importData`, `exportData`
|
||||||
|
- `services`: missing `show`
|
||||||
|
- `shopRecommendations`: missing `show`, `importData`, `exportData`
|
||||||
|
- `shopTimings`: missing `setDefault`, `removeDefault`, `importData`, `exportData`
|
||||||
|
- `shopTypes`: missing `show`, `importData`, `exportData`
|
||||||
|
- `taxes`: missing `show`, `importData`, `exportData`
|
||||||
|
- `vehicles`: the UI uses inherited `importData` and `exportData`, but the direct `import()` and `export()` alias methods are unused
|
||||||
|
|
||||||
|
## Corrections Versus `feature-checklist.md`
|
||||||
|
|
||||||
|
Under the stricter checklist rule, lookup-only usage did not count as UI coverage. Under the user rule for this audit, it does.
|
||||||
|
|
||||||
|
That changes the outcome materially:
|
||||||
|
|
||||||
|
- The old checklist had 21 `Client Only` resources
|
||||||
|
- The actual zero-usage client list under the current rule is 7 clients
|
||||||
|
|
||||||
|
Previously marked as `Client Only`, but already exercised somewhere in dashboard source under this rule:
|
||||||
|
|
||||||
|
- countries and states through `geo`
|
||||||
|
- customer types through `customers.listCustomerTypes`
|
||||||
|
- referral sources
|
||||||
|
- payment terms
|
||||||
|
- payment modes
|
||||||
|
- labels
|
||||||
|
- quick notes
|
||||||
|
- quick remarks
|
||||||
|
- reasons
|
||||||
|
- labor rates through `inventory.listLaborRates`
|
||||||
|
|
||||||
|
The remaining true zero-usage areas are concentrated in:
|
||||||
|
|
||||||
|
- holidays
|
||||||
|
- invoice sequences
|
||||||
|
- time sheets
|
||||||
|
- service-group includes, parts, pricings, and services
|
||||||
|
|
||||||
|
## Recommended Order
|
||||||
|
|
||||||
|
1. Close the real backend Phase 1 gaps first: estimate workflow depth, history or reporting endpoints, stock intelligence, technician-workload feeds, visible job-card closure, and missing email or currency configuration.
|
||||||
|
2. Close the client-layer omissions against existing Swagger: roles, invoice-sequence defaults, make/model helper routes, inspection-level attachment or label routes, and job-card expense or internal-note routes.
|
||||||
|
3. Build UI coverage for the 7 completely unused clients, then finish the high-signal partial workflows, especially expenses, inspections, inventory, job cards, vendors, service groups, shop calendars, and tasks.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -14,4 +14,9 @@ export class PurchaseOrdersClient extends CrudClient<
|
|||||||
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
constructor(baseUrl?: string, defaultOptions?: ApiClientOptions) {
|
||||||
super(baseUrl, defaultOptions, PURCHASE_ORDER_ROUTES.INDEX, PURCHASE_ORDER_ROUTES.BY_ID)
|
super(baseUrl, defaultOptions, PURCHASE_ORDER_ROUTES.INDEX, PURCHASE_ORDER_ROUTES.BY_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getById(id: string) {
|
||||||
|
const res= await this.list({query: { id }})
|
||||||
|
return { ...res, data: (res as any)?.data[0] ?? res }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user