fix build

This commit is contained in:
2026-04-15 04:59:05 +03:00
parent 020ffccfd6
commit 973149e974
28 changed files with 2352 additions and 89 deletions
@@ -2,6 +2,7 @@ import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { EstimateActions } from '@/modules/estimates/estimate-actions'
import { EstimateProvider } from '@/modules/estimates/estimate-context'
import { CreateInvoiceFromEstimateButton } from '@/modules/estimates/create-invoice-from-estimate-button'
import { FileTextIcon } from 'lucide-react'
import React from 'react'
@@ -20,7 +21,7 @@ export default async function layout(props: {
: title
return (
<EstimateProvider estimate={{ id, label: estimateLabel }}>
<EstimateProvider estimate={{ id, label: estimateLabel, data: estimateData }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
icon={<FileTextIcon className="size-5" />}
@@ -29,7 +30,12 @@ export default async function layout(props: {
estimateData?.date ? `Date: ${estimateData.date}` : undefined
}
backHref="/sales/estimates"
actions={<EstimateActions estimateId={id} />}
actions={
<div className="flex items-center gap-2">
<CreateInvoiceFromEstimateButton />
<EstimateActions estimateId={id} />
</div>
}
tabs={[
{ href: `/sales/estimates/${id}`, label: 'Details' },
]}
@@ -1,5 +1,8 @@
import { getServerApi } from '@garage/api/server'
import { EstimateGeneralInfo } from '@/modules/estimates/estimate-general-info'
import { EstimateServicesSection } from '@/modules/estimates/estimate-services-section'
import { EstimatePartsSection } from '@/modules/estimates/estimate-parts-section'
import { EstimateExpenseItemsSection } from '@/modules/estimates/estimate-expense-items-section'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
@@ -15,7 +18,13 @@ export default async function page(props: { params: Promise<{ id: string }> }) {
return (
<DashboardPage header={null}>
<EstimateGeneralInfo estimate={estimateData} />
<div className="grid gap-6">
<EstimateGeneralInfo estimate={estimateData} />
<EstimateServicesSection estimateId={id} />
<EstimatePartsSection estimateId={id} />
<EstimateExpenseItemsSection estimateId={id} />
</div>
</DashboardPage>
)
}
@@ -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>
@@ -25,6 +25,12 @@ export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
crudProps: Omit<CrudResourceProps<TClient>, "render" | "tableProps">
/** Optional rowKey for selection identity. Defaults to "id" */
rowKey?: keyof ResourceItem<TClient>
/**
* Selection mode.
* - "multi" (default): checkbox multi-select + Confirm button
* - "single": row click immediately fires onConfirm with the clicked row
*/
selectionMode?: "single" | "multi"
}
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
@@ -34,6 +40,7 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
onConfirm,
crudProps,
rowKey,
selectionMode = "multi",
}: ResourceSelectorDialogProps<TClient>) {
type TItem = ResourceItem<TClient>
@@ -58,6 +65,11 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
onOpenChange(false)
}
const handleRowClick = (row: TItem) => {
onConfirm([row])
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
@@ -67,26 +79,35 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
<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 } : {}),
},
}}
/>
{selectionMode === "single" ? (
<CrudResource<TClient>
{...crudProps}
onRowClick={handleRowClick}
/>
) : (
<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>
{selectionMode === "multi" && (
<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,59 @@
"use client"
import { useState } from "react"
import { useAuthApi } from "../useApi"
import { toast } from "sonner"
import type { DocumentPrintMode, DocumentPrintType } from "@garage/api"
interface UseDocumentPrintOptions {
onSuccess?: (blob: Blob) => void
onError?: (error: Error) => void
}
export function useDocumentPrint(options?: UseDocumentPrintOptions) {
const api = useAuthApi()
const [isPrinting, setIsPrinting] = useState(false)
const print = async (
type: DocumentPrintType,
id: string | number,
mode: DocumentPrintMode = "print",
filename?: string,
) => {
setIsPrinting(true)
try {
const blob = await api.documentPrint.print(type, id, mode)
const url = URL.createObjectURL(blob)
if (mode === "print") {
const win = window.open(url, "_blank")
if (win) {
win.addEventListener("load", () => {
win.print()
URL.revokeObjectURL(url)
})
} else {
URL.revokeObjectURL(url)
}
} else {
const a = document.createElement("a")
a.href = url
a.download = filename ?? `${type}-${id}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
options?.onSuccess?.(blob)
} catch (error) {
toast.error("Failed to print document")
options?.onError?.(error as Error)
} finally {
setIsPrinting(false)
}
}
return { print, isPrinting }
}