This commit is contained in:
2026-04-07 06:32:40 +03:00
parent 24a44481a0
commit 11db1e6941
101 changed files with 54585 additions and 39160 deletions
@@ -0,0 +1,33 @@
"use client"
import { CrudDialog } from "@/shared/components/crud-dialog"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { useAuthApi } from "@/shared/useApi"
import { INSURANCE_TYPE_ROUTES } from "@garage/api"
import { InsuranceTypeForm } from "./insurance-type-form"
export function InsuranceTypeCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Insurance Type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
getClient={() => api.insuranceTypes}
resourceLabel="insurance type"
columns={() => [
{
accessorKey: "name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<InsuranceTypeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}
@@ -0,0 +1,87 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react"
// ── Schema ──
const insuranceTypeSchema = z.object({
name: z.string().min(1, "Name is required"),
})
type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema>
// ── Props ──
type InsuranceTypeFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<InsuranceTypeFormValues>({
resolver: zodResolver(insuranceTypeSchema),
defaultValues: { name: "" },
})
// Pre-fill when editing
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({ name: d.name ?? "" })
}
}, [initialData, form])
const handleSubmit = async (values: InsuranceTypeFormValues) => {
try {
const promise = isEditing
? api.insuranceTypes.update(resourceId!, { title: values.name } as any)
: api.insuranceTypes.create({ title: values.name } as any)
toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...",
success: isEditing ? "Updated successfully" : "Created successfully",
error: isEditing ? "Failed to update" : "Failed to create",
})
await promise
form.reset()
onSuccess?.()
} catch {
// toast already shown
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Comprehensive"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update" : "Create")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -27,6 +27,7 @@ type JobCardActionsProps = {
orderDate?: string | null
serviceWriterName?: string | null
salesPersonName?: string | null
primaryTechnicianName?: string | null
}
// ── Informative Action Card ──
@@ -104,13 +105,13 @@ function EmployeePickerDialog({
// ── Main Component ──
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName, primaryTechnicianName }: JobCardActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
const [primaryTechnicianDialogOpen, setPrimaryTechnicianDialogOpen] = useState(false)
const changeDateMutation = useMutation({
mutationFn: (date: Date) => {
@@ -161,6 +162,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
},
})
return (
<div className="flex flex-wrap items-stretch gap-2">
{/* Check-in Date Action Card */}
@@ -213,6 +216,19 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
/>
</button>
{/* Primary Technician Action Card */}
<button
type="button"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
onClick={() => setPrimaryTechnicianDialogOpen(true)}
>
<ActionCard
icon={UserCheck}
label="Primary Technician"
value={primaryTechnicianName ?? null}
isPending={false}
/>
</button>
{/* Edit / Delete Dropdown */}
@@ -234,6 +250,24 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={salesPersonDialogOpen}
onOpenChange={setSalesPersonDialogOpen}
title="Change Sales Person"
description="Search and select an employee to assign as sales person."
isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
/>
<EmployeePickerDialog
open={primaryTechnicianDialogOpen}
onOpenChange={setPrimaryTechnicianDialogOpen}
title="Change Primary Technician"
description="Search and select an employee to assign as primary technician."
isPending={false}
onSelect={()=>{}}
// onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)}
/>
</div>
)
}
@@ -2,16 +2,16 @@
import { createContext, useContext, useState, useCallback } from "react"
import type { JobCardStatus } from "./job-card.schema"
import { JobCardResponseData } from "@garage/api"
import { JobCardShowData } from "../../../../packages/api/src/clients/job-cards"
const JobCardContext = createContext<JobCardResponseData | null>(null)
const JobCardContext = createContext<JobCardShowData | null>(null)
export function JobCardProvider({
jobCard,
children,
}: {
jobCard: JobCardResponseData
jobCard: JobCardShowData
children: React.ReactNode
}) {
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
@@ -21,7 +21,7 @@ export function JobCardProvider({
}, [])
return (
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardShowData}>
{children}
</JobCardContext.Provider>
)
@@ -4,14 +4,16 @@ import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfCheckboxField,
RhfSelectField,
RhfAsyncSelectField,
RhfDateField,
RhfTimeField,
RhfAutoGenerateField,
} from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
@@ -22,16 +24,15 @@ import { toRelation, toId } from "@/shared/lib/utils"
import {
jobCardFormSchema,
type JobCardFormValues,
TAX_INCLUSIVE_OPTIONS,
DISCOUNT_TYPE_OPTIONS,
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
} from "./job-card.schema"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, INSURANCE_TYPE_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
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"
// ── Props ──
@@ -49,15 +50,32 @@ const DEFAULT_VALUES: JobCardFormValues = {
vehicle: null,
department: null,
service_writer: null,
primary_technician: null,
sales_person: null,
insurance_type: null,
insurer: null,
order_number: "",
estimate_number: "",
status: "check_in",
estimate_to: "Customer",
tax_inclusive: "Tax Inclusive",
discount_type: "no",
discount_at: "inclusive_of_tax",
// check_in_date: "",
// check_in_time: (() => { const n = new Date(); return `${String(n.getHours()).padStart(2,"0")}:${String(n.getMinutes()).padStart(2,"0")}:${String(n.getSeconds()).padStart(2,"0")}` })(),
order_date: new Date().toISOString().split("T")[0],
check_in_date: "",
check_in_time: "",
start_date: "",
start_time: "",
delivery_date: "",
delivery_time: "",
km_in: "",
fuel_level: "",
has_insurance: false,
enable_parts_issuing: false,
enable_digital_authorisation: false,
footer: "",
customer_remarks: [],
labels: [],
}
// ── Mapping helpers ──
@@ -65,21 +83,56 @@ const DEFAULT_VALUES: JobCardFormValues = {
function mapToFormValues(data: unknown): JobCardFormValues {
const d = (data as any)?.data ?? data ?? {}
const mapTime = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
if (v.includes("T")) return v.split("T")[1]?.slice(0, 8) ?? ""
return v
}
const mapDate = (v: unknown): string => {
if (!v || typeof v !== "string") return ""
return v.split("T")[0]
}
return {
title: d.title || "",
customer: toRelation(d.customer_id, d.customer ? `${d.customer.first_name} ${d.customer.last_name}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? `${d.vehicle.make} ${d.vehicle.model}` : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle ? (d.vehicle.plate_number ?? `${d.vehicle.make ?? ""} ${d.vehicle.model ?? ""}`.trim()) : undefined),
department: toRelation(d.department_id, d.department?.name),
service_writer: toRelation(d.service_writer_id, d.service_writer ? `${d.service_writer.first_name} ${d.service_writer.last_name}` : undefined),
primary_technician: toRelation(d.primary_technician_id, d.primary_technician ? `${d.primary_technician.first_name} ${d.primary_technician.last_name}` : undefined),
sales_person: toRelation(d.sales_person_id, d.sales_person ? `${d.sales_person.first_name} ${d.sales_person.last_name}` : undefined),
insurance_type: toRelation(d.insurance_type_id, d.insurance_type?.name),
insurer: toRelation(d.insurer_id, d.insurer ? `${d.insurer.first_name} ${d.insurer.last_name}`.trim() || d.insurer.company_name : undefined),
order_number: d.order_number || "",
estimate_number: d.estimate_number || "",
status: d.status || "draft",
estimate_to: d.estimate_to || "Customer",
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
discount_type: d.discount_type || "no",
discount_at: d.discount_at || "inclusive_of_tax",
check_in_date: d.check_in_date ? d.check_in_date.split("T")[0] : "",
check_in_time: d.check_in_time ? d.check_in_time.split("T")[0] : "",
order_date: mapDate(d.order_date),
check_in_date: mapDate(d.check_in_date),
check_in_time: mapTime(d.check_in_time),
start_date: mapDate(d.start_date),
start_time: mapTime(d.start_time),
delivery_date: mapDate(d.delivery_date),
delivery_time: mapTime(d.delivery_time),
km_in: d.km_in != null ? String(d.km_in) : "",
fuel_level: d.fuel_level || "",
has_insurance: d.has_insurance ?? false,
enable_parts_issuing: d.enable_parts_issuing ?? false,
enable_digital_authorisation: d.enable_digital_authorisation ?? false,
footer: d.footer || "",
customer_remarks: Array.isArray(d.customer_remarks)
? d.customer_remarks.map((r: any) =>
typeof r === "string" ? r : (r?.remark ?? "")
).filter(Boolean)
: [],
labels: (d.labels ?? []).map((l: any) => ({
id: l.id,
title: l.title,
color_code: l.color_code,
})),
}
}
@@ -90,15 +143,32 @@ function mapFormToPayload(values: JobCardFormValues) {
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
service_writer_id: toId(values.service_writer),
primary_technician_id: toId(values.primary_technician),
sales_person_id: toId(values.sales_person),
insurance_type_id: values.insurance_type ? String(toId(values.insurance_type)) : null,
insurer_id: values.insurer ? String(toId(values.insurer)) : null,
estimate_number: values.estimate_number || undefined,
order_number: values.order_number || undefined,
status: values.status || undefined,
estimate_to: values.estimate_to || undefined,
tax_inclusive: values.tax_inclusive || undefined,
discount_type: values.discount_type || undefined,
discount_at: values.discount_at || undefined,
order_date: values.order_date || undefined,
check_in_date: values.check_in_date || undefined,
check_in_time: values.check_in_time || undefined,
start_date: values.start_date || undefined,
start_time: values.start_time || undefined,
delivery_date: values.delivery_date || undefined,
delivery_time: values.delivery_time || undefined,
km_in: values.km_in ? Number(values.km_in) : undefined,
fuel_level: values.fuel_level || undefined,
has_insurance: values.has_insurance,
enable_parts_issuing: values.enable_parts_issuing,
enable_digital_authorisation: values.enable_digital_authorisation,
footer: values.footer || undefined,
customer_remarks: values.customer_remarks?.filter(Boolean) ?? [],
label_ids: values.labels?.map((l) => l.id),
}
}
@@ -130,6 +200,10 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
mapToFormValues,
})
const hasInsurance = form.watch("has_insurance")
const status = form.watch("status")
const isCheckIn = status === "check_in"
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: JobCardFormValues) => {
const payload = mapFormToPayload(values)
@@ -161,93 +235,144 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
{/* ── Left column ── */}
<div className="space-y-4">
<RhfLabelPickerField name="labels" label="Labels" />
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<RhfCheckboxField name="has_insurance" label="Has Insurance Work?" />
{hasInsurance && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCustomerSelectField
name="insurer"
label="Insurer"
placeholder="Search insurer..."
customerType="Insurer"
/>
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">Insurance Type</span>
<InsuranceTypeCrudDialog />
</div>
<RhfAsyncSelectField
name="insurance_type"
label=""
placeholder="Select insurance type"
queryKey={[INSURANCE_TYPE_ROUTES.INDEX]}
listFn={() => api.insuranceTypes.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<RhfDateField name="order_date" label="Order Date" />
<RhfAutoGenerateField autoFetch name="order_number" label="Order#" placeholder="ORD-001" table="job_cards" />
<RhfAutoGenerateField autoFetch name="estimate_number" label="Estimate#" placeholder="EST-001" table="estimates" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="service_writer"
label="Service Writer"
placeholder="Select service writer"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="primary_technician"
label="Primary Technician"
placeholder="Select technician"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
</div>
{/* ── Check-in Details (shown when status is check_in) ── */}
{isCheckIn && (
<div className="space-y-4 rounded-lg border p-4">
<p className="text-sm font-semibold">Check In Details</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_in_date" label="Check In Date" />
<RhfTimeField name="check_in_time" label="Check In Time" />
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<RhfTextField name="km_in" label="KMs IN" placeholder="0" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select"
options={FUEL_LEVEL_OPTIONS}
/>
<RhfDateField name="start_date" label="Start Date" />
<RhfTimeField name="start_time" label="Start Time" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_1fr_1fr]">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfDateField name="delivery_date" label="Delivery Date" />
<RhfTimeField name="delivery_time" label="Delivery Time" />
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* <RhfSelectField
{/* ── Right column ── */}
<div className="space-y-4">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={JOB_CARD_STATUS_OPTIONS}
/> */}
<RhfSelectField
name="estimate_to"
label="Estimate To"
placeholder="Select estimate to"
options={ESTIMATE_TO_OPTIONS}
/>
</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={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="service_writer"
label="Service Writer"
placeholder="Select service writer"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
</div>
{/* <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_in_date" label="Check-in Date" />
<RhfTimeField name="check_in_time" label="Check-in Time" withSeconds />
</div> */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_in" label="KM In" placeholder="50000" type="number" />
<RhfSelectField
name="fuel_level"
label="Fuel Level"
placeholder="Select fuel level"
options={FUEL_LEVEL_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<RhfSelectField
name="tax_inclusive"
label="Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="discount_type"
label="Discount Type"
placeholder="Select discount type"
options={DISCOUNT_TYPE_OPTIONS}
/>
<RhfSelectField
name="discount_at"
label="Discount At"
placeholder="Select discount at"
options={DISCOUNT_AT_OPTIONS}
/>
<RhfCustomerRemarksField name="customer_remarks" />
<RhfTextareaField name="footer" label="Estimate Footer" placeholder="Thank you for your business." rows={6} />
<RhfCheckboxField
name="enable_parts_issuing"
label="Enable Parts Issuing"
description="When off, parts will be auto issued upon authorization. When on, manually request, reserve, and issue parts."
/>
<RhfCheckboxField
name="enable_digital_authorisation"
label="Enable Digital Authorisation"
description="When off, items will be auto authorised. When on, customer approval is required before proceeding."
/>
</div>
</div>
<div className="mt-6">
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Job Card" : "Create Job Card")}
</Button>
</FieldGroup>
</div>
</Rhform>
)
}
@@ -4,7 +4,6 @@ import {
Hash,
Users,
Car,
Building2,
Gauge,
Clock,
UserCheck,
@@ -21,15 +20,19 @@ import {
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
import { JobCardActions } from "./job-card-actions"
import { JobCardRemarksList } from "./job-card-remarks-list"
import { JobCardRecommendationsList } from "./job-card-recommendations-list"
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
import { JobCardResponseData } from "@garage/api"
import { getFullName } from "@/shared/utils/getFullName"
import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received"
type JobCardGeneralInfoProps = {
jobCard: JobCardResponseData
}
type JobCard = NonNullable<CrudShowResponse<JobCardsClient>['data']>
function InfoItem({
icon: Icon,
@@ -64,7 +67,7 @@ const statusColorMap: Record<string, string> = {
cancelled: "destructive",
}
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
const formatStatus = (status?: string) => {
if (!status) return null
return status
@@ -73,163 +76,183 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }
.join(" ")
}
console.log(jobCard)
return (
<div className="flex flex-col gap-6">
{jobCard.service_writer?.first_name}
<JobCardActions
jobCardId={String(jobCard.id)}
jobCardId={String(jobCard)}
orderDate={jobCard.order_date ?? null}
serviceWriterName={jobCard.service_writer?.first_name}
salesPersonName={jobCard.sales_person?.first_name}
primaryTechnicianName={jobCard.primary_technician?.first_name}
/>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4" />
Job Card Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{jobCard.title && (
<Badge variant="secondary">{jobCard.title}</Badge>
)}
{jobCard.status && (
<Badge variant={statusColorMap[jobCard.status] as any ?? "outline"}>
{formatStatus(jobCard.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Check-in Date"
value={jobCard.check_in_date}
/>
<InfoItem
icon={Gauge}
label="KM In"
value={jobCard.km_in ? Number(jobCard.km_in).toLocaleString() : null}
/>
<InfoItem
icon={Clock}
label="Created"
value={jobCard.created_at ? new Date(jobCard.created_at).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Updated"
value={jobCard.updated_at ? new Date(jobCard.updated_at).toLocaleDateString() : null}
/>
</div>
</CardContent>
</Card>
{/* Related Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Related Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Users}
label="Customer"
value={getFullName(jobCard.customer)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={getVehicleLabel(jobCard.vehicle as any)}
/>
<InfoItem
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4" />
Job Card Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{jobCard.title && (
<Badge variant="secondary">{jobCard.title}</Badge>
)}
{jobCard.status && (
<Badge variant={statusColorMap[jobCard.status] as any ?? "outline"}>
{formatStatus(jobCard.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Check-in Date"
value={jobCard.check_in_date}
/>
<InfoItem
icon={Gauge}
label="KM In"
value={jobCard.km_in ? Number(jobCard.km_in).toLocaleString() : null}
/>
<InfoItem
icon={Clock}
label="Created"
value={jobCard.created_at ? new Date(jobCard.created_at).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Updated"
value={jobCard.updated_at ? new Date(jobCard.updated_at).toLocaleDateString() : null}
/>
</div>
</CardContent>
</Card>
{/* Related Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Related Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Users}
label="Customer"
value={getFullName(jobCard?.customer)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={getVehicleLabel(jobCard?.vehicle as any)}
/>
{/* <InfoItem
icon={Building2}
label="Department"
value={jobCard.department as any}
value={jobCard?.department?.name as any}
/> */}
<InfoItem
icon={Briefcase}
label="Sales Person"
value={getFullName(jobCard.sales_person as any)}
/>
<InfoItem
icon={UserCheck}
label="Service Writer"
value={getFullName(jobCard?.service_writer as any)}
/>
</div>
</CardContent>
</Card>
{/* Tax & Discount Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Tax & Discount Settings
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Tax Inclusive"
value={jobCard.tax_inclusive}
/>
<InfoItem
icon={Briefcase}
label="Sales Person"
value={getFullName(jobCard.sales_person as any)}
icon={DollarSign}
label="Discount Type"
value={formatStatus(jobCard.discount_type)}
/>
<InfoItem
icon={UserCheck}
label="Service Writer"
value={getFullName(jobCard.service_writer as any)}
icon={DollarSign}
label="Discount At"
value={formatStatus(jobCard.discount_at)}
/>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Tax & Discount Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Tax & Discount Settings
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Tax Inclusive"
value={jobCard.tax_inclusive}
/>
<InfoItem
icon={DollarSign}
label="Discount Type"
value={formatStatus(jobCard.discount_type)}
/>
<InfoItem
icon={DollarSign}
label="Discount At"
value={formatStatus(jobCard.discount_at)}
/>
</CardContent>
</Card>
{/* Counts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Hash className="size-4" />
Related Counts
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Purchase Orders"
value={String(jobCard.purchase_orders_count ?? 0)}
/>
<InfoItem
icon={Receipt}
label="Bills"
value={String(jobCard.bills_count ?? 0)}
/>
<InfoItem
icon={DollarSign}
label="Expenses"
value={String(jobCard.expenses_count ?? 0)}
/>
<InfoItem
icon={ClipboardList}
label="Tasks"
value={String(jobCard.tasks_count ?? 0)}
/>
<InfoItem
icon={Calendar}
label="Appointments"
value={String(jobCard.appointments_count ?? 0)}
/>
</CardContent>
</Card>
</div>
{/* Counts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Hash className="size-4" />
Related Counts
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Receipt}
label="Purchase Orders"
value={String(jobCard.purchase_orders_count ?? 0)}
/>
<InfoItem
icon={Receipt}
label="Bills"
value={String(jobCard.bills_count ?? 0)}
/>
<InfoItem
icon={DollarSign}
label="Expenses"
value={String(jobCard.expenses_count ?? 0)}
/>
<InfoItem
icon={ClipboardList}
label="Tasks"
value={String(jobCard.tasks_count ?? 0)}
/>
<InfoItem
icon={Calendar}
label="Appointments"
value={String(jobCard.appointments_count ?? 0)}
/>
</CardContent>
</Card>
<JobCardPaymentsReceived />
<div className="grid gap-6 md:grid-cols-2">
<JobCardRemarksList
jobCardId={String(jobCard.id)}
remarks={(jobCard as any).customer_remarks ?? []}
/>
<JobCardRecommendationsList
jobCardId={String(jobCard.id)}
recommendations={(jobCard as any).shop_recommendations ?? []}
/>
</div>
</div>
)
@@ -0,0 +1,244 @@
"use client"
import React from "react"
import { AlertTriangle, Plus, Save } 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,
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"
// ── Schema ──
const jobCardPartFormSchema = z.object({
part: z
.object({ value: z.string(), label: z.string() })
.nullable(),
department: 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"),
tax: z.string().optional(),
description: z.string().optional(),
})
type JobCardPartFormValues = z.infer<typeof jobCardPartFormSchema>
// ── Props ──
export type JobCardPartFormProps = {
jobCardId: string
jobCardPartId?: number | null
initialData?: unknown
onSuccess?: () => void
onCancel?: () => void
}
const DEFAULT_VALUES: JobCardPartFormValues = {
part: null,
department: null,
quantity: 1,
rate: 0,
tax: "",
description: "",
}
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): JobCardPartFormValues {
const d = (data as any) ?? {}
return {
part: d.part
? { value: String(d.part.id), label: d.part.title ?? String(d.part.id) }
: null,
department: d.department
? { value: String(d.department.id), label: d.department.name ?? String(d.department.id) }
: null,
quantity: d.quantity ?? 1,
rate: d.rate != null ? Number(d.rate) : 0,
tax: d.tax ?? "",
description: d.description ?? "",
}
}
// ── Component ──
export function JobCardPartForm({
jobCardId,
jobCardPartId,
initialData,
onSuccess,
onCancel,
}: JobCardPartFormProps) {
const api = useAuthApi()
const isEditing = !!jobCardPartId
const form = useForm<JobCardPartFormValues>({
resolver: zodResolver(jobCardPartFormSchema) 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: JobCardPartFormValues) {
setError(null)
setIsPending(true)
try {
if (isEditing && jobCardPartId) {
await toast.promise(
api.jobCards.updatePart(jobCardId, {
job_card_part_id: jobCardPartId,
quantity: values.quantity,
rate: values.rate,
description: values.description || undefined,
}),
{
loading: "Updating part...",
success: "Part updated successfully",
error: "Failed to update part",
}
)
} else {
await toast.promise(
api.jobCards.addPart(jobCardId, {
part_id: values.part ? Number(values.part.value) : undefined,
department_id: values.department ? Number(values.department.value) : undefined,
quantity: values.quantity,
rate: values.rate,
tax: values.tax || undefined,
description: values.description || undefined,
}),
{
loading: "Adding part...",
success: "Part added successfully",
error: "Failed to add part",
}
)
}
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 part" : "Failed to add part"}
</AlertTitle>
{error}
</Alert>
)}
<FieldGroup>
{!isEditing && (
<RhfAsyncSelectField
name="part"
label="Part"
placeholder="Select part"
required
queryKey={["parts"]}
listFn={() => api.parts.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.title ?? 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">
<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>
)}
<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}>
Cancel
</Button>
)}
<Button type="submit" disabled={isPending}>
{isPending ? (
isEditing ? "Saving..." : "Adding..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Save Changes
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Add Part
</>
)}
</Button>
</div>
</Rhform>
)
}
@@ -0,0 +1,134 @@
"use client"
import { CrudResource } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import FormDialog from "@/shared/components/form-dialog"
import { PaymentReceivedForm } from "@/modules/payment-received/payment-received-form"
import { PAYMENT_RECEIVED_ROUTES, PaymentReceivedClient } from "@garage/api"
import {
BadgeDollarSignIcon,
CalendarIcon,
ChevronDown,
CreditCardIcon,
HashIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Button } from "@/shared/components/ui/button"
import { useJobCard } from "./job-card-context"
import { formatDate, formatCurrency } from "@/shared/utils/formatters"
export default function JobCardPaymentsReceived() {
const jobCard = useJobCard()
return (
<Collapsible defaultOpen={false} className="group/collapsible">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Payments Received</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-180" />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
<CrudResource<PaymentReceivedClient>
extraParams={{ job_card_id: jobCard?.id }}
routeKey={PAYMENT_RECEIVED_ROUTES.INDEX}
getClient={(api) => api.paymentReceived}
tableHeader={({ invalidateQuery }) =>
<div className="p-2">
<FormDialog title="Record Payment">
{(resourceId) => (
<PaymentReceivedForm
resourceId={resourceId}
defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }}
onSuccess={invalidateQuery}
/>
)}
</FormDialog>
</div>
}
columns={({ actionsColumn }) => [
{
accessorKey: "payment_number",
header: ({ column }) => <ColumnHeader column={column} title="Payment #" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.payment_number || "—"}</span>
</div>
)
},
},
{
accessorKey: "amount_received",
header: ({ column }) => <ColumnHeader column={column} title="Amount" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<BadgeDollarSignIcon className="h-4 w-4 text-emerald-600" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400">
{formatCurrency(item.amount_received)}
</span>
</div>
)
},
},
{
accessorKey: "payment_mode_name",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => {
const item = row.original as any
return (
<div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span>
</div>
)
},
},
{
accessorKey: "payment_date",
header: ({ column }) => <ColumnHeader column={column} title="Date" />,
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<span>{formatDate(item.payment_date)}</span>
</div>
)
},
},
{
accessorKey: "note",
header: () => <span>Note</span>,
enableSorting: false,
cell: ({ row }) => {
const item = row.original
const note = item.note
if (!note) return <span className="text-muted-foreground">—</span>
return (
<span className="max-w-50 truncate block" title={note}>
{note}
</span>
)
},
},
actionsColumn(),
]}
/>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, Lightbulb } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type ShopRecommendation = {
id: number
recommendation: string
created_at?: string
}
type Props = {
jobCardId: string
recommendations: ShopRecommendation[]
}
export function JobCardRecommendationsList({ jobCardId, recommendations }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRecommendation, setNewRecommendation] = useState("")
const addMutation = useMutation({
mutationFn: (recommendation: string) =>
api.jobCards.addShopRecommendation(jobCardId, { recommendation }),
onSuccess: () => {
toast.success("Shop recommendation added")
setNewRecommendation("")
router.refresh()
},
onError: () => {
toast.error("Failed to add shop recommendation")
},
})
const deleteMutation = useMutation({
mutationFn: (recommendationId: number) =>
api.jobCards.deleteShopRecommendation(jobCardId, recommendationId),
onSuccess: () => {
toast.success("Shop recommendation deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete shop recommendation")
},
})
const handleAdd = () => {
const trimmed = newRecommendation.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (recommendation: ShopRecommendation) => {
const confirmed = await confirm({
title: "Delete Shop Recommendation",
description: "Are you sure you want to delete this recommendation?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(recommendation.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="size-4" />
Shop Recommendations
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{recommendations.length === 0 && (
<p className="text-sm text-muted-foreground">No shop recommendations yet.</p>
)}
{recommendations.map((rec) => (
<div
key={rec.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{rec.recommendation}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(rec)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a shop recommendation..."
value={newRecommendation}
onChange={(e) => setNewRecommendation(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRecommendation.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Recommendation"}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,128 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { Plus, Trash2, MessageSquare } from "lucide-react"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import { confirm } from "@/shared/components/confirm-dialog"
import { Button } from "@/shared/components/ui/button"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
type CustomerRemark = {
id: number
remark: string
created_at?: string
}
type Props = {
jobCardId: string
remarks: CustomerRemark[]
}
export function JobCardRemarksList({ jobCardId, remarks }: Props) {
const api = useAuthApi()
const router = useRouter()
const [newRemark, setNewRemark] = useState("")
const addMutation = useMutation({
mutationFn: (remark: string) =>
api.jobCards.addCustomerRemark(jobCardId, { remark }),
onSuccess: () => {
toast.success("Customer remark added")
setNewRemark("")
router.refresh()
},
onError: () => {
toast.error("Failed to add customer remark")
},
})
const deleteMutation = useMutation({
mutationFn: (remarkId: number) =>
api.jobCards.deleteCustomerRemark(jobCardId, remarkId),
onSuccess: () => {
toast.success("Customer remark deleted")
router.refresh()
},
onError: () => {
toast.error("Failed to delete customer remark")
},
})
const handleAdd = () => {
const trimmed = newRemark.trim()
if (!trimmed) return
addMutation.mutate(trimmed)
}
const handleDelete = async (remark: CustomerRemark) => {
const confirmed = await confirm({
title: "Delete Customer Remark",
description: "Are you sure you want to delete this remark?",
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) {
deleteMutation.mutate(remark.id)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" />
Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{remarks.length === 0 && (
<p className="text-sm text-muted-foreground">No customer remarks yet.</p>
)}
{remarks.map((remark) => (
<div
key={remark.id}
className="flex items-start justify-between gap-3 rounded-lg border px-4 py-3"
>
<p className="text-sm whitespace-pre-wrap">{remark.remark}</p>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(remark)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<div className="flex flex-col gap-2">
<Textarea
placeholder="Add a customer remark..."
value={newRemark}
onChange={(e) => setNewRemark(e.target.value)}
rows={2}
/>
<Button
size="sm"
className="self-end"
onClick={handleAdd}
disabled={addMutation.isPending || !newRemark.trim()}
>
<Plus className="size-4" />
{addMutation.isPending ? "Adding..." : "Add Remark"}
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -55,19 +55,56 @@ const jobCardFormSchema = z.object({
vehicle: relationFieldSchema,
department: relationFieldSchema,
service_writer: relationFieldSchema,
primary_technician: relationFieldSchema,
sales_person: relationFieldSchema,
insurance_type: relationFieldSchema,
insurer: relationFieldSchema,
// ── Settings ──
// ── Numbers & identifiers ──
order_number: z.string().optional(),
estimate_number: z.string().optional(),
// ── Status & settings ──
status: z.string().optional(),
estimate_to: z.string().optional(),
tax_inclusive: z.string().optional(),
discount_type: z.string().optional(),
discount_at: z.string().optional(),
// ── Check-in details ──
// ── Dates & times ──
order_date: z.string().optional(),
check_in_date: z.string().optional(),
check_in_time: z.string().optional(),
start_date: z.string().optional(),
start_time: z.string().optional(),
delivery_date: z.string().optional(),
delivery_time: z.string().optional(),
// ── Vehicle state ──
km_in: z.string().optional(),
fuel_level: z.string().optional(),
// ── Boolean options ──
has_insurance: z.boolean().optional(),
enable_parts_issuing: z.boolean().optional(),
enable_digital_authorisation: z.boolean().optional(),
// ── Notes ──
footer: z.string().optional(),
// ── Customer Remarks ──
customer_remarks: z.array(z.string()).optional(),
// ── Labels ──
labels: z
.array(
z.object({
id: z.number(),
title: z.string(),
color_code: z.string(),
}),
)
.optional(),
})
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
@@ -80,7 +117,6 @@ export {
DISCOUNT_AT_OPTIONS,
ESTIMATE_TO_OPTIONS,
FUEL_LEVEL_OPTIONS,
JOB_CARD_STATUS_OPTIONS,
}
// Backward-compat alias used by job-card-status-stepper
export const JOB_CARD_STATUSES = JOB_CARD_STATUS_OPTIONS