fix build
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
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 { EmployeeCombobox } from "./employee-combobox"
|
||||
import { EmployeeForm } from "./employee-form"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@@ -16,6 +30,8 @@ export type RhfEmployeeSelectFieldProps<
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Show a "+" button to create a new employee inline */
|
||||
showCreate?: boolean
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
@@ -30,6 +46,7 @@ export function RhfEmployeeSelectField<
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by name or email...",
|
||||
showCreate,
|
||||
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
@@ -37,6 +54,69 @@ export function RhfEmployeeSelectField<
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: [EMPLOYEE_ROUTES.INDEX] })
|
||||
setIsCreateOpen(false)
|
||||
}
|
||||
|
||||
const combobox = (
|
||||
<EmployeeCombobox
|
||||
value={field.value}
|
||||
onValueChange={(emp) => {
|
||||
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
if (showCreate) {
|
||||
return (
|
||||
<>
|
||||
<Field data-invalid={!!error || 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 employee"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{combobox}
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Add Employee</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<EmployeeForm onSuccess={handleCreateSuccess} />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
@@ -44,17 +124,7 @@ export function RhfEmployeeSelectField<
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<EmployeeCombobox
|
||||
value={field.value}
|
||||
onValueChange={(emp) => {
|
||||
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
{combobox}
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"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 { InvoiceForm } from "@/modules/invoices/invoice-form"
|
||||
import { toRelation } from "@/shared/lib/utils"
|
||||
import { useEstimate } from "./estimate-context"
|
||||
|
||||
/**
|
||||
* Maps an Estimate data object to an InvoiceFormValues shape so that
|
||||
* useResourceForm's shallow spread correctly pre-fills all relational fields.
|
||||
*/
|
||||
function mapEstimateToInvoiceInitialData(estimate: Record<string, any>) {
|
||||
return {
|
||||
subject: estimate.title ?? "",
|
||||
notes: estimate.footer ?? "",
|
||||
|
||||
// Relation fields — must be { value, label } objects for RhfAsyncSelectField
|
||||
customer: toRelation(estimate.customer_id, estimate.customer_name),
|
||||
vehicle: toRelation(estimate.vehicle_id, estimate.vehicle_name),
|
||||
department: toRelation(estimate.department_id, estimate.department_name),
|
||||
|
||||
invoice_number: "",
|
||||
invoice_date: estimate.date ?? "",
|
||||
due_date: "",
|
||||
status: "draft" as const,
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateInvoiceFromEstimateButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const estimateContext = useEstimate()
|
||||
|
||||
if (!estimateContext) return null
|
||||
|
||||
const initialData = estimateContext.data
|
||||
? mapEstimateToInvoiceInitialData(estimateContext.data)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileText className="me-2 size-4" />
|
||||
Generate Invoice
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="min-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Invoice from Estimate</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[75vh] px-1">
|
||||
<InvoiceForm
|
||||
initialData={initialData}
|
||||
onSuccess={() => setOpen(false)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -14,26 +14,197 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Ellipsis, Pencil, Trash2, ShieldCheck, Check, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { EstimateForm } from "./estimate-form"
|
||||
import { ESTIMATE_ROUTES } from "@garage/api"
|
||||
import { DatePickerField, TimePickerField } from "@/shared/components/form"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { EmployeeCombobox, type EmployeeOption } from "../employees/employee-combobox"
|
||||
|
||||
type EstimateActionsProps = {
|
||||
estimateId: string
|
||||
}
|
||||
|
||||
const AUTHORISATION_METHOD_OPTIONS = [
|
||||
{ value: "in_person", label: "In Person" },
|
||||
{ value: "phone", label: "Phone" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "online", label: "Online" },
|
||||
]
|
||||
|
||||
type ServiceLine = { id: number; labor_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
|
||||
type PartLine = { id: number; title?: string; quantity: number; rate: number | string; description?: string }
|
||||
type ExpenseLine = { id: number; item_name?: string; title?: string; quantity: number; rate: number | string; description?: string }
|
||||
|
||||
function toggleStatus(current: string, action: "accepted" | "rejected"): string {
|
||||
return current === action ? "pending" : action
|
||||
}
|
||||
|
||||
function ItemStatusRow({
|
||||
name,
|
||||
rate,
|
||||
quantity,
|
||||
description,
|
||||
status,
|
||||
onToggle,
|
||||
}: {
|
||||
name: string
|
||||
rate: number | string
|
||||
quantity: number
|
||||
description?: string
|
||||
status: string
|
||||
onToggle: (action: "accepted" | "rejected") => void
|
||||
}) {
|
||||
const rateNum = Number(rate)
|
||||
const amount = rateNum * quantity
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-2.5 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{name}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{quantity}</span> qty
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Rate <span className="font-medium text-foreground">{rateNum.toFixed(2)}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-xs font-semibold text-foreground">{amount.toFixed(2)}</span>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3",
|
||||
status === "rejected" && "bg-destructive text-destructive-foreground border-destructive hover:bg-destructive/90",
|
||||
)}
|
||||
onClick={() => onToggle("rejected")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3",
|
||||
status === "accepted" && "bg-primary text-primary-foreground border-primary hover:bg-primary/90",
|
||||
)}
|
||||
onClick={() => onToggle("accepted")}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading({ title }: { title: string }) {
|
||||
return (
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">{title}</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [authOpen, setAuthOpen] = useState(false)
|
||||
const [itemStatuses, setItemStatuses] = useState<Record<string, string>>({})
|
||||
const [authMethod, setAuthMethod] = useState("in_person")
|
||||
const [employee, setEmployee] = useState<EmployeeOption | null>(null)
|
||||
const [authDate, setAuthDate] = useState("")
|
||||
const [authTime, setAuthTime] = useState("")
|
||||
|
||||
const { data: servicesData = [], isLoading: loadingServices } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.SERVICES, estimateId, "auth"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listServices(estimateId)
|
||||
return ((res as any)?.data ?? []) as ServiceLine[]
|
||||
},
|
||||
enabled: authOpen,
|
||||
})
|
||||
|
||||
const { data: partsData = [], isLoading: loadingParts } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.PARTS, estimateId, "auth"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listParts(estimateId)
|
||||
return ((res as any)?.data ?? []) as PartLine[]
|
||||
},
|
||||
enabled: authOpen,
|
||||
})
|
||||
|
||||
const { data: expenseItemsData = [], isLoading: loadingExpenseItems } = useQuery({
|
||||
queryKey: [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId, "auth"],
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listExpenseItems(estimateId)
|
||||
return ((res as any)?.data ?? []) as ExpenseLine[]
|
||||
},
|
||||
enabled: authOpen,
|
||||
})
|
||||
|
||||
const isLoading = loadingServices || loadingParts || loadingExpenseItems
|
||||
const hasItems = servicesData.length > 0 || partsData.length > 0 || expenseItemsData.length > 0
|
||||
|
||||
const getStatus = (key: string) => itemStatuses[key] ?? "pending"
|
||||
const handleToggle = (key: string, action: "accepted" | "rejected") =>
|
||||
setItemStatuses((prev) => ({ ...prev, [key]: toggleStatus(prev[key] ?? "pending", action) }))
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.estimates.destroy(estimateId)
|
||||
router.push("/sales/estimates")
|
||||
}
|
||||
|
||||
const authMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.estimates.storeAuthorisation(estimateId, {
|
||||
estimate_services: servicesData.map((s) => ({ id: s.id, status: getStatus(`s-${s.id}`) })),
|
||||
estimate_parts: partsData.map((p) => ({ id: p.id, status: getStatus(`p-${p.id}`) })),
|
||||
estimate_expense_items: expenseItemsData.map((e) => ({ id: e.id, status: getStatus(`e-${e.id}`) })),
|
||||
authorisation_method: authMethod,
|
||||
employee_id: employee ? Number(employee.value) : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Authorisation stored successfully")
|
||||
setAuthOpen(false)
|
||||
router.refresh()
|
||||
},
|
||||
onError: () => toast.error("Failed to store authorisation"),
|
||||
})
|
||||
|
||||
const openAuthDialog = () => {
|
||||
setItemStatuses({})
|
||||
setAuthMethod("in_person")
|
||||
setEmployee(null)
|
||||
const n = new Date()
|
||||
setAuthDate(n.toISOString().split("T")[0])
|
||||
setAuthTime(`${String(n.getHours()).padStart(2, "0")}:${String(n.getMinutes()).padStart(2, "0")}:00`)
|
||||
setAuthOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@@ -47,6 +218,10 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAuthDialog}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Store Authorisation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
@@ -54,6 +229,7 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="min-w-xl">
|
||||
<DialogHeader>
|
||||
@@ -70,6 +246,122 @@ export function EstimateActions({ estimateId }: EstimateActionsProps) {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Authorisation Dialog */}
|
||||
<Dialog open={authOpen} onOpenChange={setAuthOpen}>
|
||||
<DialogContent className="sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Store Authorisation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="grid gap-5 px-1 py-1">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-muted-foreground">Loading line items…</p>
|
||||
)}
|
||||
{!isLoading && !hasItems && (
|
||||
<p className="text-sm text-muted-foreground">No line items on this estimate.</p>
|
||||
)}
|
||||
|
||||
{servicesData.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading title="Services" />
|
||||
{servicesData.map((s) => (
|
||||
<ItemStatusRow
|
||||
key={s.id}
|
||||
name={s.labor_name || s.title || `#${s.id}`}
|
||||
rate={s.rate}
|
||||
quantity={s.quantity}
|
||||
description={s.description}
|
||||
status={getStatus(`s-${s.id}`)}
|
||||
onToggle={(a) => handleToggle(`s-${s.id}`, a)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partsData.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading title="Parts" />
|
||||
{partsData.map((p) => (
|
||||
<ItemStatusRow
|
||||
key={p.id}
|
||||
name={p.title || `#${p.id}`}
|
||||
rate={p.rate}
|
||||
quantity={p.quantity}
|
||||
description={p.description}
|
||||
status={getStatus(`p-${p.id}`)}
|
||||
onToggle={(a) => handleToggle(`p-${p.id}`, a)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expenseItemsData.length > 0 && (
|
||||
<div>
|
||||
<SectionHeading title="Expense Items" />
|
||||
{expenseItemsData.map((e) => (
|
||||
<ItemStatusRow
|
||||
key={e.id}
|
||||
name={e.item_name || e.title || `#${e.id}`}
|
||||
rate={e.rate}
|
||||
quantity={e.quantity}
|
||||
description={e.description}
|
||||
status={getStatus(`e-${e.id}`)}
|
||||
onToggle={(a) => handleToggle(`e-${e.id}`, a)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="grid gap-3 border-t pt-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Date</p>
|
||||
<DatePickerField value={authDate} onChange={(v) => setAuthDate(v ?? "")} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Time</p>
|
||||
<TimePickerField value={authTime} onChange={(v) => setAuthTime(v ?? "")} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Method</p>
|
||||
<Select value={authMethod} onValueChange={setAuthMethod}>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTHORISATION_METHOD_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Employee</p>
|
||||
<EmployeeCombobox
|
||||
value={employee}
|
||||
onValueChange={setEmployee}
|
||||
showClear={!!employee}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAuthOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => authMutation.mutate()} disabled={authMutation.isPending || isLoading}>
|
||||
{authMutation.isPending ? "Saving…" : "Save Authorisations"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
type EstimateContextValue = {
|
||||
export type EstimateContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
const EstimateContext = createContext<EstimateContextValue | null>(null)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { z } from "zod"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { toast } from "sonner"
|
||||
import { DEPARTMENT_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const schema = z.object({
|
||||
quantity: z.coerce.number().min(1, "Quantity is required"),
|
||||
rate: z.string().min(1, "Rate is required"),
|
||||
description: z.string().optional(),
|
||||
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
|
||||
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
export type EstimateExpenseItemConfigFormProps = {
|
||||
expenseItem: { id: number; name?: string; purchase_price?: string | number }
|
||||
estimateId: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function EstimateExpenseItemConfigForm({
|
||||
expenseItem,
|
||||
estimateId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: EstimateExpenseItemConfigFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema) as any,
|
||||
defaultValues: {
|
||||
quantity: 1,
|
||||
rate: expenseItem.purchase_price != null ? String(expenseItem.purchase_price) : "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
const promise = api.estimates.addExpenseItem(estimateId, {
|
||||
expense_item_id: expenseItem.id,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
description: values.description || undefined,
|
||||
department_id: values.department ? Number(values.department.value) : undefined,
|
||||
|
||||
} as any)
|
||||
toast.promise(promise, {
|
||||
loading: "Adding expense item...",
|
||||
success: "Expense item added successfully",
|
||||
error: "Failed to add expense item",
|
||||
})
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "An unexpected error occurred")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to add expense item</AlertTitle>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
|
||||
{expenseItem.name ?? `Expense Item #${expenseItem.id}`}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="quantity"
|
||||
label="Quantity"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="rate"
|
||||
label="Rate"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add Expense Item"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { ESTIMATE_ROUTES, EXPENSE_ITEM_ROUTES } from "@garage/api"
|
||||
import type { ExpenseItemsClient } from "@garage/api"
|
||||
import { expenseItemColumns } from "@/modules/expense-items/expense-items-columns"
|
||||
import { EstimateExpenseItemConfigForm } from "./estimate-expense-item-config-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ExpenseLine = {
|
||||
id: number
|
||||
expense_item_id?: number
|
||||
item_name?: string
|
||||
title?: string
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SelectedExpenseItem = {
|
||||
id: number
|
||||
name?: string
|
||||
purchase_price?: string | number
|
||||
}
|
||||
|
||||
export function EstimateExpenseItemsSection({ estimateId }: { estimateId: string }) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [configExpenseItem, setConfigExpenseItem] = useState<SelectedExpenseItem | null>(null)
|
||||
|
||||
const queryKey = [ESTIMATE_ROUTES.EXPENSE_ITEMS, estimateId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listExpenseItems(estimateId)
|
||||
return ((res as any)?.data ?? []) as ExpenseLine[]
|
||||
},
|
||||
})
|
||||
|
||||
const items: ExpenseLine[] = data ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ lineId, payload }: { lineId: string; payload: { quantity?: number } }) =>
|
||||
api.estimates.updateExpenseItem(estimateId, lineId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Expense item updated")
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error("Failed to update expense item"),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (lineId: string) => api.estimates.removeExpenseItem(estimateId, lineId),
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error("Failed to remove expense item"),
|
||||
})
|
||||
|
||||
const handlePickerConfirm = (rows: any[]) => {
|
||||
const row = rows[0]
|
||||
if (!row) return
|
||||
setConfigExpenseItem({
|
||||
id: row.id,
|
||||
name: row.name ?? row.title,
|
||||
purchase_price: row.purchase_price,
|
||||
})
|
||||
}
|
||||
|
||||
const getDisplayName = (item: ExpenseLine) =>
|
||||
item.item_name ?? item.title ?? `Item #${item.expense_item_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Expense Items</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add Expense Item
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{!isLoading && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No expense items added yet.</p>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>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.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onBlur={(e) =>
|
||||
updateMutation.mutate({
|
||||
lineId: String(item.id),
|
||||
payload: { quantity: Number(e.target.value) || 1 },
|
||||
})
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground tabular-nums">
|
||||
{Number(item.rate).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.description || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMutation.mutate(String(item.id))}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Step 1: Pick an expense item (single-select) */}
|
||||
<ResourceSelectorDialog<ExpenseItemsClient>
|
||||
title="Select Expense Item"
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selectionMode="single"
|
||||
onConfirm={handlePickerConfirm}
|
||||
crudProps={{
|
||||
routeKey: EXPENSE_ITEM_ROUTES.INDEX,
|
||||
getClient: (api) => api.expenseItems,
|
||||
columns: [
|
||||
expenseItemColumns.name,
|
||||
expenseItemColumns.purchasePrice,
|
||||
expenseItemColumns.chartOfAccount,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Step 2: Configure and add the selected expense item */}
|
||||
<Dialog open={!!configExpenseItem} onOpenChange={(v) => { if (!v) setConfigExpenseItem(null) }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Configure Expense Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh] px-1">
|
||||
{configExpenseItem && (
|
||||
<EstimateExpenseItemConfigForm
|
||||
expenseItem={configExpenseItem}
|
||||
estimateId={estimateId}
|
||||
onSuccess={() => {
|
||||
setConfigExpenseItem(null)
|
||||
invalidate()
|
||||
}}
|
||||
onCancel={() => setConfigExpenseItem(null)}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { z } from "zod"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { toast } from "sonner"
|
||||
import { DEPARTMENT_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const schema = z.object({
|
||||
quantity: z.coerce.number().min(1, "Quantity is required"),
|
||||
rate: z.string().min(1, "Rate is required"),
|
||||
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export type EstimatePartConfigFormProps = {
|
||||
part: { id: number; title?: string; purchase_price?: string | number }
|
||||
estimateId: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function EstimatePartConfigForm({
|
||||
part,
|
||||
estimateId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: EstimatePartConfigFormProps) {
|
||||
const api = useAuthApi()
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema) as any,
|
||||
defaultValues: {
|
||||
quantity: 1,
|
||||
rate: part.purchase_price != null ? String(part.purchase_price) : "",
|
||||
department: null,
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
const promise = api.estimates.addPart(estimateId, {
|
||||
part_id: part.id,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
department_id: values.department ? Number(values.department.value) : undefined,
|
||||
description: values.description || undefined,
|
||||
} as any)
|
||||
toast.promise(promise, {
|
||||
loading: "Adding part...",
|
||||
success: "Part added successfully",
|
||||
error: "Failed to add part",
|
||||
})
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "An unexpected error occurred")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to add part</AlertTitle>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
|
||||
{part.title ?? `Part #${part.id}`}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="quantity"
|
||||
label="Quantity"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="rate"
|
||||
label="Rate"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add Part"}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { PARTS_ROUTES, ESTIMATE_ROUTES } from "@garage/api"
|
||||
import type { PartsClient } from "@garage/api"
|
||||
import { partColumns } from "@/modules/parts/parts-columns"
|
||||
import { EstimatePartConfigForm } from "./estimate-part-config-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type PartLine = {
|
||||
id: number
|
||||
part_id?: number
|
||||
title?: string
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SelectedPart = {
|
||||
id: number
|
||||
title?: string
|
||||
purchase_price?: string | number
|
||||
}
|
||||
|
||||
export function EstimatePartsSection({ estimateId }: { estimateId: string }) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [configPart, setConfigPart] = useState<SelectedPart | null>(null)
|
||||
|
||||
const queryKey = [ESTIMATE_ROUTES.PARTS, estimateId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listParts(estimateId)
|
||||
return ((res as any)?.data ?? []) as PartLine[]
|
||||
},
|
||||
})
|
||||
|
||||
const items: PartLine[] = data ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ lineId, payload }: { lineId: string; payload: { quantity?: number } }) =>
|
||||
api.estimates.updatePart(estimateId, lineId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Part updated")
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error("Failed to update part"),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (lineId: string) => api.estimates.removePart(estimateId, lineId),
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error("Failed to remove part"),
|
||||
})
|
||||
|
||||
const handlePickerConfirm = (rows: any[]) => {
|
||||
const row = rows[0]
|
||||
if (!row) return
|
||||
setConfigPart({
|
||||
id: row.id,
|
||||
title: row.title ?? row.name,
|
||||
purchase_price: row.purchase_price,
|
||||
})
|
||||
}
|
||||
|
||||
const getDisplayName = (item: PartLine) =>
|
||||
item.title ?? `Part #${item.part_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Parts</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add Part
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{!isLoading && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No parts added yet.</p>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<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.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onBlur={(e) =>
|
||||
updateMutation.mutate({
|
||||
lineId: String(item.id),
|
||||
payload: { quantity: Number(e.target.value) || 1 },
|
||||
})
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground tabular-nums">
|
||||
{Number(item.rate).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.description || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMutation.mutate(String(item.id))}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Step 1: Pick a part (single-select) */}
|
||||
<ResourceSelectorDialog<PartsClient>
|
||||
title="Select Part"
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selectionMode="single"
|
||||
onConfirm={handlePickerConfirm}
|
||||
crudProps={{
|
||||
routeKey: PARTS_ROUTES.INDEX,
|
||||
getClient: (api) => api.parts,
|
||||
columns: [
|
||||
partColumns.title,
|
||||
partColumns.partNumber,
|
||||
partColumns.manufacturer,
|
||||
partColumns.purchasePrice,
|
||||
partColumns.stock,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Step 2: Configure and add the selected part */}
|
||||
<Dialog open={!!configPart} onOpenChange={(v) => { if (!v) setConfigPart(null) }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Configure Part</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh] px-1">
|
||||
{configPart && (
|
||||
<EstimatePartConfigForm
|
||||
part={configPart}
|
||||
estimateId={estimateId}
|
||||
onSuccess={() => {
|
||||
setConfigPart(null)
|
||||
invalidate()
|
||||
}}
|
||||
onCancel={() => setConfigPart(null)}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { z } from "zod"
|
||||
import { AlertTriangle, Plus } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { RateType, DEPARTMENT_ROUTES, INVENTORY_ROUTES } from "@garage/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const schema = z.object({
|
||||
rate_type: z.string().optional(),
|
||||
labor_rate: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
|
||||
quantity: z.coerce.number().min(1, "Quantity is required"),
|
||||
rate: z.string().min(1, "Rate is required"),
|
||||
working_hours: z.string().optional(),
|
||||
labor_hours: z.string().optional(),
|
||||
tax: z.string().optional(),
|
||||
chart_of_account: z.string().optional(),
|
||||
department: z.object({ value: z.string(), label: z.string() }).nullable().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const DEFAULT_VALUES: FormValues = {
|
||||
rate_type: "flat_rate",
|
||||
labor_rate: null,
|
||||
quantity: 1,
|
||||
rate: "",
|
||||
working_hours: "",
|
||||
labor_hours: "",
|
||||
tax: "",
|
||||
chart_of_account: "",
|
||||
department: null,
|
||||
description: "",
|
||||
}
|
||||
|
||||
const RATE_TYPE_OPTIONS = RateType.map((v) => ({
|
||||
value: v,
|
||||
label: v === "flat_rate" ? "Flat Rate" : "Hourly",
|
||||
}))
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
export type EstimateServiceConfigFormProps = {
|
||||
/** The selected service row from the picker dialog */
|
||||
service: { id: number; labor_name?: string; selling_price?: string | number }
|
||||
estimateId: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function EstimateServiceConfigForm({
|
||||
service,
|
||||
estimateId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: EstimateServiceConfigFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema) as any,
|
||||
defaultValues: {
|
||||
...DEFAULT_VALUES,
|
||||
rate: service.selling_price != null ? String(service.selling_price) : "",
|
||||
},
|
||||
})
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
const promise = api.estimates.addService(estimateId, {
|
||||
service_id: service.id,
|
||||
rate_type: values.rate_type || undefined,
|
||||
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate || undefined,
|
||||
working_hours: values.working_hours || undefined,
|
||||
labor_hours: values.labor_hours || undefined,
|
||||
tax: values.tax || undefined,
|
||||
chart_of_account: values.chart_of_account || undefined,
|
||||
department_id: values.department ? Number(values.department.value) : undefined,
|
||||
description: values.description || undefined,
|
||||
})
|
||||
toast.promise(promise, {
|
||||
loading: "Adding service...",
|
||||
success: "Service added successfully",
|
||||
error: "Failed to add service",
|
||||
})
|
||||
await promise
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "An unexpected error occurred")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to add service</AlertTitle>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-sm font-medium">
|
||||
{service.labor_name ?? `Service #${service.id}`}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="quantity"
|
||||
label="Quantity"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
required
|
||||
/>
|
||||
<RhfTextField
|
||||
name="rate"
|
||||
label="Rate"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="rate_type"
|
||||
label="Rate Type"
|
||||
options={RATE_TYPE_OPTIONS}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="labor_rate"
|
||||
label="Labor Rate"
|
||||
placeholder="Select labor rate"
|
||||
queryKey={[INVENTORY_ROUTES.LABOR_RATES]}
|
||||
listFn={() => api.inventory.listLaborRates()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.title ?? String(item.id),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField
|
||||
name="working_hours"
|
||||
label="Working Hours"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="labor_hours"
|
||||
label="Labor Hours"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? String(item.id),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="tax"
|
||||
label="Tax (%)"
|
||||
placeholder="e.g. 5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextField
|
||||
name="chart_of_account"
|
||||
label="Chart of Account"
|
||||
placeholder="e.g. 4000"
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Adding..." : (
|
||||
<>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add Service
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { ResourceSelectorDialog } from "@/shared/components/resource-selector/resource-selector-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { SERVICE_ROUTES, ESTIMATE_ROUTES } from "@garage/api"
|
||||
import type { ServicesClient } from "@garage/api"
|
||||
import { serviceColumns } from "@/modules/services/services-columns"
|
||||
import { EstimateServiceConfigForm } from "./estimate-service-config-form"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ServiceLine = {
|
||||
id: number
|
||||
service_id?: number
|
||||
labor_name?: string
|
||||
title?: string
|
||||
quantity: number
|
||||
rate: number | string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SelectedService = {
|
||||
id: number
|
||||
labor_name?: string
|
||||
selling_price?: string | number
|
||||
}
|
||||
|
||||
export function EstimateServicesSection({ estimateId }: { estimateId: string }) {
|
||||
const api = useAuthApi()
|
||||
const queryClient = useQueryClient()
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [configService, setConfigService] = useState<SelectedService | null>(null)
|
||||
|
||||
const queryKey = [ESTIMATE_ROUTES.SERVICES, estimateId]
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.estimates.listServices(estimateId)
|
||||
return ((res as any)?.data ?? []) as ServiceLine[]
|
||||
},
|
||||
})
|
||||
|
||||
const items: ServiceLine[] = data ?? []
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey })
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ lineId, payload }: { lineId: string; payload: { rate?: string; quantity?: number } }) =>
|
||||
api.estimates.updateService(estimateId, lineId, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Service updated")
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error("Failed to update service"),
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (lineId: string) => api.estimates.removeService(estimateId, lineId),
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error("Failed to remove service"),
|
||||
})
|
||||
|
||||
const handlePickerConfirm = (rows: any[]) => {
|
||||
const row = rows[0]
|
||||
if (!row) return
|
||||
setConfigService({
|
||||
id: row.id,
|
||||
labor_name: row.labor_name ?? row.name,
|
||||
selling_price: row.selling_price,
|
||||
})
|
||||
}
|
||||
|
||||
const getDisplayName = (item: ServiceLine) =>
|
||||
item.labor_name ?? item.title ?? `Service #${item.service_id ?? item.id}`
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Services</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => setPickerOpen(true)}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add Service
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{!isLoading && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No services added yet.</p>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<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.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">{getDisplayName(item)}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onBlur={(e) =>
|
||||
updateMutation.mutate({
|
||||
lineId: String(item.id),
|
||||
payload: { quantity: Number(e.target.value) || 1 },
|
||||
})
|
||||
}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
defaultValue={Number(item.rate)}
|
||||
onBlur={(e) =>
|
||||
updateMutation.mutate({
|
||||
lineId: String(item.id),
|
||||
payload: { rate: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{item.description || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeMutation.mutate(String(item.id))}
|
||||
disabled={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Step 1: Pick a service (single-select) */}
|
||||
<ResourceSelectorDialog<ServicesClient>
|
||||
title="Select Service"
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selectionMode="single"
|
||||
onConfirm={handlePickerConfirm}
|
||||
crudProps={{
|
||||
routeKey: SERVICE_ROUTES.INDEX,
|
||||
getClient: (api) => api.services,
|
||||
columns: [
|
||||
serviceColumns.name,
|
||||
serviceColumns.description,
|
||||
serviceColumns.sellingPrice,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Step 2: Configure and add the selected service */}
|
||||
<Dialog open={!!configService} onOpenChange={(v) => { if (!v) setConfigService(null) }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">Configure Service</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh] px-1">
|
||||
{configService && (
|
||||
<EstimateServiceConfigForm
|
||||
service={configService}
|
||||
estimateId={estimateId}
|
||||
onSuccess={() => {
|
||||
setConfigService(null)
|
||||
invalidate()
|
||||
}}
|
||||
onCancel={() => setConfigService(null)}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,21 @@ import { useRouter } from 'next/dist/client/components/navigation';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/components/ui/dropdown-menu'
|
||||
import { Button } from '@/shared/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Ellipsis, Pencil, Printer, Trash2 } from 'lucide-react';
|
||||
import { useDocumentPrint } from '@/shared/hooks/use-document-print';
|
||||
|
||||
export default function JobCardDropdown({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const { print, isPrinting } = useDocumentPrint()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${id}/edit`)
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
print("job_card", id, "print")
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
@@ -45,6 +53,10 @@ export default function JobCardDropdown({ id }: { id: string }) {
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePrint} disabled={isPrinting}>
|
||||
<Printer className="size-4" />
|
||||
{isPrinting ? "Printing..." : "Print"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -190,7 +190,7 @@ export function JobCardFilterFields() {
|
||||
name="customer_id"
|
||||
label="Customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list({ per_page: 100 })}
|
||||
listFn={() => api.customers.list( )}
|
||||
mapOption={customerMapOption}
|
||||
placeholder="All customers"
|
||||
/>
|
||||
@@ -199,7 +199,7 @@ export function JobCardFilterFields() {
|
||||
name="vehicle_id"
|
||||
label="Vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list({ per_page: 100 })}
|
||||
listFn={() => api.vehicles.list( )}
|
||||
mapOption={vehicleMapOption}
|
||||
placeholder="All vehicles"
|
||||
/>
|
||||
@@ -208,7 +208,7 @@ export function JobCardFilterFields() {
|
||||
name="insurer_id"
|
||||
label="Insurance Type"
|
||||
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
|
||||
listFn={() => api.insuranceTypes.list({ per_page: 100 })}
|
||||
listFn={() => api.insuranceTypes.list( )}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
||||
placeholder="All insurers"
|
||||
/>
|
||||
@@ -217,7 +217,7 @@ export function JobCardFilterFields() {
|
||||
name="department_id"
|
||||
label="Department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list({ per_page: 100 })}
|
||||
listFn={() => api.departments.list( )}
|
||||
mapOption={(item: any) => ({ value: String(item.id), label: item.title ?? item.name ?? `#${item.id}` })}
|
||||
placeholder="All departments"
|
||||
/>
|
||||
@@ -229,7 +229,7 @@ export function JobCardFilterFields() {
|
||||
name="sales_person_id"
|
||||
label="Sales Person"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "sales_person"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All sales persons"
|
||||
/>
|
||||
@@ -238,7 +238,7 @@ export function JobCardFilterFields() {
|
||||
name="primary_technician_id"
|
||||
label="Primary Technician"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "technician"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All technicians"
|
||||
/>
|
||||
@@ -247,7 +247,7 @@ export function JobCardFilterFields() {
|
||||
name="service_writer_id"
|
||||
label="Service Writer"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX, "service_writer"]}
|
||||
listFn={() => api.employees.list({ per_page: 100 })}
|
||||
listFn={() => api.employees.list( )}
|
||||
mapOption={employeeMapOption}
|
||||
placeholder="All service writers"
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-fie
|
||||
import { RhfLabelPickerField } from "@/modules/labels/rhf-label-picker-field"
|
||||
import { RhfCustomerRemarksField } from "@/modules/estimates/rhf-customer-remarks-field"
|
||||
import { InsuranceTypeCrudDialog } from "./insurance-type-crud-dialog"
|
||||
import { RhfEmployeeSelectField } from "../employees/rhf-employee-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@@ -282,22 +283,18 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
<RhfEmployeeSelectField
|
||||
showCreate
|
||||
name="service_writer"
|
||||
label="Service Writer"
|
||||
placeholder="Select service writer"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={mapEmployeeOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
/>
|
||||
<RhfEmployeeSelectField
|
||||
showCreate
|
||||
name="primary_technician"
|
||||
label="Primary Technician"
|
||||
placeholder="Select technician"
|
||||
queryKey={[EMPLOYEE_ROUTES.INDEX]}
|
||||
listFn={() => api.employees.list()}
|
||||
mapOption={mapEmployeeOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user