update
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { z } from "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 { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { RateType } from "@garage/api"
|
||||
|
||||
// ── Schema ──
|
||||
|
||||
const jobCardServiceFormSchema = z.object({
|
||||
service: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable(),
|
||||
department: z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
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.coerce.number().min(0, "Rate is required"),
|
||||
working_hours: z.coerce.number().min(0).optional(),
|
||||
labor_hours: z.coerce.number().min(0).optional(),
|
||||
tax: z.string().optional(),
|
||||
chart_of_account: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type JobCardServiceFormValues = z.infer<typeof jobCardServiceFormSchema>
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type JobCardServiceFormProps = {
|
||||
jobCardId: string
|
||||
jobCardServiceId?: number | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES: JobCardServiceFormValues = {
|
||||
service: null,
|
||||
department: null,
|
||||
rate_type: "flat_rate",
|
||||
labor_rate: null,
|
||||
quantity: 1,
|
||||
rate: 0,
|
||||
working_hours: 0,
|
||||
labor_hours: 0,
|
||||
tax: "",
|
||||
chart_of_account: "",
|
||||
description: "",
|
||||
}
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
function mapToFormValues(data: unknown): JobCardServiceFormValues {
|
||||
const d = (data as any) ?? {}
|
||||
return {
|
||||
service: d.service
|
||||
? { value: String(d.service.id), label: d.service.labor_name ?? String(d.service.id) }
|
||||
: null,
|
||||
department: d.department
|
||||
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
|
||||
: null,
|
||||
rate_type: d.rate_type ?? "flat_rate",
|
||||
labor_rate: d.labor_rate
|
||||
? { value: String(d.labor_rate.id), label: d.labor_rate.title ?? String(d.labor_rate.id) }
|
||||
: null,
|
||||
quantity: d.quantity ?? 1,
|
||||
rate: d.rate != null ? Number(d.rate) : 0,
|
||||
working_hours: d.working_hours != null ? Number(d.working_hours) : 0,
|
||||
labor_hours: d.labor_hours != null ? Number(d.labor_hours) : 0,
|
||||
tax: d.tax ?? "",
|
||||
chart_of_account: d.chart_of_account ?? "",
|
||||
description: d.description ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
const RATE_TYPE_OPTIONS = RateType.map((v) => ({
|
||||
value: v,
|
||||
label: v === "flat_rate" ? "Flat Rate" : "Hourly",
|
||||
}))
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function JobCardServiceForm({
|
||||
jobCardId,
|
||||
jobCardServiceId,
|
||||
initialData,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: JobCardServiceFormProps) {
|
||||
const api = useAuthApi()
|
||||
const isEditing = !!jobCardServiceId
|
||||
|
||||
const form = useForm<JobCardServiceFormValues>({
|
||||
resolver: zodResolver(jobCardServiceFormSchema) as any,
|
||||
defaultValues: initialData
|
||||
? mapToFormValues(initialData)
|
||||
: DEFAULT_VALUES,
|
||||
})
|
||||
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
|
||||
async function handleSubmit(values: JobCardServiceFormValues) {
|
||||
setError(null)
|
||||
setIsPending(true)
|
||||
try {
|
||||
if (isEditing && jobCardServiceId) {
|
||||
await toast.promise(
|
||||
api.jobCards.updateService(jobCardId, {
|
||||
job_card_service_id: jobCardServiceId,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
{
|
||||
loading: "Updating service...",
|
||||
success: "Service updated successfully",
|
||||
error: "Failed to update service",
|
||||
}
|
||||
)
|
||||
} else {
|
||||
await toast.promise(
|
||||
api.jobCards.addService(jobCardId, {
|
||||
service_id: values.service ? Number(values.service.value) : undefined,
|
||||
department_id: values.department ? Number(values.department.value) : undefined,
|
||||
rate_type: values.rate_type || undefined,
|
||||
labor_rate_id: values.labor_rate ? Number(values.labor_rate.value) : undefined,
|
||||
quantity: values.quantity,
|
||||
rate: values.rate,
|
||||
working_hours: values.working_hours || undefined,
|
||||
labor_hours: values.labor_hours || undefined,
|
||||
tax: values.tax || undefined,
|
||||
chart_of_account: values.chart_of_account || undefined,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
{
|
||||
loading: "Adding service...",
|
||||
success: "Service added successfully",
|
||||
error: "Failed to add service",
|
||||
}
|
||||
)
|
||||
}
|
||||
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>
|
||||
{isEditing ? "Failed to update service" : "Failed to add service"}
|
||||
</AlertTitle>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{!isEditing && (
|
||||
<RhfAsyncSelectField
|
||||
name="service"
|
||||
label="Service"
|
||||
placeholder="Select service"
|
||||
required
|
||||
queryKey={["services"]}
|
||||
listFn={() => api.services.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.labor_name ?? String(item.id),
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="rate_type"
|
||||
label="Rate Type"
|
||||
placeholder="Select rate type"
|
||||
options={RATE_TYPE_OPTIONS}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="labor_rate"
|
||||
label="Labor Rate"
|
||||
placeholder="Select labor rate"
|
||||
queryKey={["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"
|
||||
/>
|
||||
<RhfTextField
|
||||
name="labor_hours"
|
||||
label="Labor Hours"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={["departments"]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? String(item.id),
|
||||
})}
|
||||
createForm={(props) => <DepartmentInlineForm {...props} />}
|
||||
createLabel="Department"
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField
|
||||
name="tax"
|
||||
label="Tax"
|
||||
placeholder="e.g. 5%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextField
|
||||
name="chart_of_account"
|
||||
label="Chart of Account"
|
||||
placeholder="e.g. COA-200"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RhfTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
placeholder="Optional notes"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? isEditing ? "Updating..." : "Adding..."
|
||||
: isEditing ? "Update Service" : "Add Service"}
|
||||
</Button>
|
||||
</div>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user