fix many bugs

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Mohammad Khyata 2026-04-30 19:03:31 +03:00
parent 0c43c8edd2
commit e1ef6fa2ea
24 changed files with 333 additions and 82 deletions

View File

@ -6,10 +6,12 @@ import FormDialog from '@/shared/components/form-dialog'
import { EstimateForm } from '@/modules/estimates/estimate-form' import { EstimateForm } from '@/modules/estimates/estimate-form'
import { ESTIMATE_ROUTES } from '@garage/api' import { ESTIMATE_ROUTES } from '@garage/api'
import type { EstimatesClient } from '@garage/api' import type { EstimatesClient } from '@garage/api'
import { Car, FileTextIcon } from 'lucide-react' import { Car, FileTextIcon, UserIcon } from 'lucide-react'
import { Button } from '@/shared/components/ui/button' import { Button } from '@/shared/components/ui/button'
import Link from 'next/link' import Link from 'next/link'
import { formatDate } from '@/shared/utils/formatters' import { formatDate } from '@/shared/utils/formatters'
import { getVehicleLabel } from '@/modules/vehicles/utils/getVehicleLabel'
import { getFullName } from '@/shared/utils/getFullName'
export default function EstimatesPage() { export default function EstimatesPage() {
return ( return (
@ -52,15 +54,24 @@ export default function EstimatesPage() {
{ {
accessorKey: "customer_name", accessorKey: "customer_name",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />, header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const item:any = row.original
return (
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{getFullName(item.customer) || "—"}</span>
</div>
)
}
}, },
{ {
accessorKey: "vehicle_id", accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />, header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original const item :any= row.original
return <Button variant="outline" asChild size="sm"> return <Button variant="outline" asChild size="sm">
<Link href={`/sales/vehicles/${item.vehicle_id}`}> <Link href={`/sales/vehicles/${item.vehicle?.id}`}>
<Car/> Go to vehicle <Car/> {getVehicleLabel(item.vehicle as any) || "—"}
</Link> </Link>
</Button> </Button>
} }

View File

@ -13,6 +13,7 @@ import {
UserIcon, UserIcon,
ClipboardListIcon, ClipboardListIcon,
} from "lucide-react" } from "lucide-react"
import { getFullName } from "@/shared/utils/getFullName"
type PaymentReceivedItem = { type PaymentReceivedItem = {
id: number id: number
@ -63,24 +64,24 @@ export default function PaymentReceivedPage() {
}, },
}, },
{ {
accessorKey: "customer_name", accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />, header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem const item:any = row.original as unknown as PaymentReceivedItem
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-muted-foreground" /> <UserIcon className="h-4 w-4 text-muted-foreground" />
<span>{item.customer_name || "—"}</span> <span>{getFullName(item.customer) || "—"}</span>
</div> </div>
) )
}, },
}, },
{ {
accessorKey: "job_card_name", accessorKey: "job_card",
header: ({ column }) => <ColumnHeader column={column} title="Job Card" />, header: ({ column }) => <ColumnHeader column={column} title="Job Card" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem const item:any = row.original as unknown as PaymentReceivedItem
const label = item.job_card_number || item.job_card_name const label = item.job_card?.title
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ClipboardListIcon className="h-4 w-4 text-muted-foreground" /> <ClipboardListIcon className="h-4 w-4 text-muted-foreground" />
@ -111,14 +112,14 @@ export default function PaymentReceivedPage() {
}, },
}, },
{ {
accessorKey: "payment_mode_name", accessorKey: "payment_mode",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />, header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as unknown as PaymentReceivedItem const item:any = row.original as unknown as PaymentReceivedItem
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" /> <CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span> <span className="capitalize">{item.payment_mode?.title || "—"}</span>
</div> </div>
) )
}, },

View File

@ -11,6 +11,7 @@ import {
RhfTextareaField, RhfTextareaField,
RhfSelectField, RhfSelectField,
RhfAsyncSelectField, RhfAsyncSelectField,
RhfTimeField,
} from "@/shared/components/form" } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
@ -40,8 +41,8 @@ export type AppointmentFormProps = {
const DEFAULT_VALUES: AppointmentFormValues = { const DEFAULT_VALUES: AppointmentFormValues = {
title: "", title: "",
date: "", date: "",
from_time: "", from_time: "00:00:00",
to_time: "", to_time: "00:00:00",
customer: null, customer: null,
vehicle: null, vehicle: null,
service_writer: null, service_writer: null,
@ -157,8 +158,8 @@ export function AppointmentForm({ resourceId, initialData, onSuccess }: Appointm
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<RhfTextField name="from_time" label="From Time" placeholder="HH:MM" type="time" /> <RhfTimeField name="from_time" label="From Time" placeholder="HH:MM" />
<RhfTextField name="to_time" label="To Time" placeholder="HH:MM" type="time" /> <RhfTimeField name="to_time" label="To Time" placeholder="HH:MM" />
</div> </div>
</FieldGroup> </FieldGroup>

View File

@ -21,7 +21,8 @@ import {
customerFormSchema, customerFormSchema,
type CustomerFormValues, type CustomerFormValues,
} from "./customer.schema" } from "./customer.schema"
import { CUSTOMER_ROUTES } from "@garage/api" import { CUSTOMER_ROUTES, PAYMENT_TERM_ROUTES } from "@garage/api"
import { PaymentTermCrudDialog } from "./payment-term-crud-dialog"
// ── Constants ── // ── Constants ──
@ -69,7 +70,7 @@ function mapCustomerToFormValues(data: unknown): CustomerFormValues {
const c = (data as any)?.data ?? data ?? {} const c = (data as any)?.data ?? data ?? {}
return { return {
customer_type: toRelation(c.customer_type_id, c.customer_type_name), customer_type: toRelation(c.customer_type_id, c.customer_type?.name ?? c.customer_type_name),
referral_source: toRelation(c.referral_source_id, c.referral_source_name), referral_source: toRelation(c.referral_source_id, c.referral_source_name),
payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name), payment_terms: toRelation(c.payment_terms_id, c.payment_terms_name),
country: toRelation(c.country_id, c.country_name), country: toRelation(c.country_id, c.country_name),
@ -113,10 +114,10 @@ function mapFormToPayload(values: CustomerFormValues) {
const mapLookupOption = (item: any) => ({ const mapLookupOption = (item: any) => ({
value: String(item.id), value: String(item.id),
label: item.name, label: item.title||item.name || `#${item.id}`,
}) })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label || o.name || `#${o.id}` }
// ── Component ── // ── Component ──
@ -173,6 +174,7 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
options={SALUTATION_OPTIONS} options={SALUTATION_OPTIONS}
/> />
<RhfAsyncSelectField <RhfAsyncSelectField
required
name="customer_type" name="customer_type"
label="Customer Type" label="Customer Type"
placeholder="Select customer type" placeholder="Select customer type"
@ -211,15 +213,21 @@ export function CustomerForm({ resourceId, initialData, onSuccess }: CustomerFor
mapOption={mapLookupOption} mapOption={mapLookupOption}
{...STORE_OBJECT} {...STORE_OBJECT}
/> />
<RhfAsyncSelectField <div>
name="payment_terms" <div className="flex items-center justify-between mb-1">
label="Payment Terms" <span className="text-sm font-medium">Payment Terms</span>
placeholder="Select payment terms" <PaymentTermCrudDialog />
queryKey={["payment-terms"]} </div>
listFn={() => api.paymentTerms.list()} <RhfAsyncSelectField
mapOption={mapLookupOption} name="payment_terms"
{...STORE_OBJECT} label=""
/> placeholder="Select payment terms"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
</div> </div>
{/* Address */} {/* Address */}

View File

@ -12,7 +12,7 @@ type RelationField = z.infer<typeof relationFieldSchema>
const customerFormSchema = z.object({ const customerFormSchema = z.object({
// ── Relations (stored as objects, mapped to IDs on submit) ── // ── Relations (stored as objects, mapped to IDs on submit) ──
customer_type: relationFieldSchema, customer_type: relationFieldSchema.refine((val) => !!val?.value, "Customer type is required"),
referral_source: relationFieldSchema, referral_source: relationFieldSchema,
payment_terms: relationFieldSchema, payment_terms: relationFieldSchema,
country: relationFieldSchema, country: relationFieldSchema,

View File

@ -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 { PAYMENT_TERM_ROUTES } from "@garage/api"
import { PaymentTermForm } from "./payment-term-form"
export function PaymentTermCrudDialog() {
const api = useAuthApi()
return (
<CrudDialog
title="Payment Terms"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
getClient={() => api.paymentTerms}
resourceLabel="payment term"
columns={() => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
},
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<PaymentTermForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,86 @@
"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 paymentTermSchema = z.object({
name: z.string().min(1, "Name is required"),
})
type PaymentTermFormValues = z.infer<typeof paymentTermSchema>
// ── Props ──
type PaymentTermFormProps = {
resourceId?: string | null
initialData?: any
onSuccess?: () => void
}
// ── Component ──
export function PaymentTermForm({ resourceId, initialData, onSuccess }: PaymentTermFormProps) {
const api = useAuthApi()
const isEditing = !!resourceId
const form = useForm<PaymentTermFormValues>({
resolver: zodResolver(paymentTermSchema),
defaultValues: { name: "" },
})
useEffect(() => {
if (initialData) {
const d = initialData?.data ?? initialData
form.reset({ name: d.title ?? "" })
}
}, [initialData, form])
const handleSubmit = async (values: PaymentTermFormValues) => {
try {
const promise = isEditing
? api.paymentTerms.update(resourceId!, { title: values.name } as any)
: api.paymentTerms.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. Net 30"
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>
)
}

View File

@ -31,7 +31,15 @@ export default function InvoicePaymentsSection() {
<PaymentReceivedForm <PaymentReceivedForm
invoiceId={invoice?.id as string} invoiceId={invoice?.id as string}
invoiceCustomer={invoice?.customer as any} invoiceCustomer={
(invoice?.customer as any) ??
((invoice as any)?.customer_id
? {
id: (invoice as any).customer_id,
first_name: (invoice as any).customer_name,
}
: null)
}
invoiceAmount={invoice?.balance_due as any} invoiceAmount={invoice?.balance_due as any}
resourceId={resourceId} resourceId={resourceId}
onSuccess={()=>{router.refresh(); invalidateQuery()}} onSuccess={()=>{router.refresh(); invalidateQuery()}}

View File

@ -17,9 +17,13 @@ export function InsuranceTypeCrudDialog() {
resourceLabel="insurance type" resourceLabel="insurance type"
columns={() => [ columns={() => [
{ {
accessorKey: "name", accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Name" />, header: ({ column }) => <ColumnHeader column={column} title="Name" />,
}, },
{
accessorKey: "description",
header: ({ column }) => <ColumnHeader column={column} title="Description" />,
},
]} ]}
renderForm={({ resourceId, initialData, onSuccess }) => ( renderForm={({ resourceId, initialData, onSuccess }) => (
<InsuranceTypeForm <InsuranceTypeForm

View File

@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { Plus, Save } from "lucide-react" import { Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field" import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField } from "@/shared/components/form" import { Rhform, RhfTextField, RhfTextareaField } from "@/shared/components/form"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi" import { useAuthApi } from "@/shared/useApi"
import { useEffect } from "react" import { useEffect } from "react"
@ -15,6 +15,7 @@ import { useEffect } from "react"
const insuranceTypeSchema = z.object({ const insuranceTypeSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
description: z.string().min(1, "Description is required"),
}) })
type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema> type InsuranceTypeFormValues = z.infer<typeof insuranceTypeSchema>
@ -35,22 +36,22 @@ export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: Insura
const form = useForm<InsuranceTypeFormValues>({ const form = useForm<InsuranceTypeFormValues>({
resolver: zodResolver(insuranceTypeSchema), resolver: zodResolver(insuranceTypeSchema),
defaultValues: { name: "" }, defaultValues: { name: "", description: "" },
}) })
// Pre-fill when editing // Pre-fill when editing
useEffect(() => { useEffect(() => {
if (initialData) { if (initialData) {
const d = initialData?.data ?? initialData const d = initialData?.data ?? initialData
form.reset({ name: d.name ?? "" }) form.reset({ name: d.title ?? "", description: d.description ?? "" })
} }
}, [initialData, form]) }, [initialData, form])
const handleSubmit = async (values: InsuranceTypeFormValues) => { const handleSubmit = async (values: InsuranceTypeFormValues) => {
try { try {
const promise = isEditing const promise = isEditing
? api.insuranceTypes.update(resourceId!, { title: values.name } as any) ? api.insuranceTypes.update(resourceId!, { title: values.name, description: values.description } as any)
: api.insuranceTypes.create({ title: values.name } as any) : api.insuranceTypes.create({ title: values.name, description: values.description } as any)
toast.promise(promise, { toast.promise(promise, {
loading: isEditing ? "Updating..." : "Creating...", loading: isEditing ? "Updating..." : "Creating...",
@ -75,6 +76,13 @@ export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: Insura
placeholder="e.g. Comprehensive" placeholder="e.g. Comprehensive"
required required
/> />
<RhfTextareaField
name="description"
label="Description"
placeholder="e.g. Comprehensive insurance coverage for vehicles"
rows={3}
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>
{isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />} {isEditing ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{form.formState.isSubmitting {form.formState.isSubmitting

View File

@ -20,7 +20,7 @@ import { Calendar } from "@/shared/components/ui/calendar"
import { toast } from "sonner" import { toast } from "sonner"
import { Pencil, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react" import { Pencil, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
import { format } from "date-fns" import { format } from "date-fns"
import { EmployeeCombobox } from "@/modules/employees/employee-combobox" import { EmployeeCombobox, type EmployeeOption } from "@/modules/employees/employee-combobox"
type JobCardActionsProps = { type JobCardActionsProps = {
jobCardId: string jobCardId: string
@ -82,6 +82,16 @@ function EmployeePickerDialog({
isPending, isPending,
onSelect, onSelect,
}: EmployeePickerDialogProps) { }: EmployeePickerDialogProps) {
const handleSelect = (emp: EmployeeOption | null) => {
console.log('Selected employee object:', emp, 'Value:', emp?.value, 'Type of value:', typeof emp?.value)
if (emp && emp.value) {
const employeeId = Number(emp.value)
console.log('Parsed employee ID:', employeeId, 'Type:', typeof employeeId)
onSelect(employeeId)
onOpenChange(false)
}
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -91,9 +101,7 @@ function EmployeePickerDialog({
</DialogHeader> </DialogHeader>
<EmployeeCombobox <EmployeeCombobox
value={null} value={null}
onValueChange={(emp) => { onValueChange={handleSelect}
if (emp) onSelect(Number(emp.value))
}}
disabled={isPending} disabled={isPending}
placeholder="Search employees..." placeholder="Search employees..."
showClear={false} showClear={false}
@ -131,7 +139,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
}) })
const changeServiceWriterMutation = useMutation({ const changeServiceWriterMutation = useMutation({
mutationFn: (employeeId: number) => { mutationFn: async (employeeId: number) => {
console.log('Sending service writer ID:', employeeId, 'Type:', typeof employeeId)
const promise = api.jobCards.changeServiceWriter(jobCardId, { service_writer_id: employeeId }) const promise = api.jobCards.changeServiceWriter(jobCardId, { service_writer_id: employeeId })
toast.promise(promise, { toast.promise(promise, {
loading: "Updating service writer...", loading: "Updating service writer...",
@ -147,7 +156,8 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
}) })
const changeSalesPersonMutation = useMutation({ const changeSalesPersonMutation = useMutation({
mutationFn: (employeeId: number) => { mutationFn: async (employeeId: number) => {
console.log('Sending sales person ID:', employeeId, 'Type:', typeof employeeId)
const promise = api.jobCards.changeSalesPerson(jobCardId, { sales_person_id: employeeId }) const promise = api.jobCards.changeSalesPerson(jobCardId, { sales_person_id: employeeId })
toast.promise(promise, { toast.promise(promise, {
loading: "Updating sales person...", loading: "Updating sales person...",
@ -162,6 +172,23 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
}, },
}) })
const changePrimaryTechnicianMutation = useMutation({
mutationFn: async (employeeId: number) => {
console.log('Sending primary technician ID:', employeeId, 'Type:', typeof employeeId)
const promise = api.jobCards.changeTechnician(jobCardId, { technician_id: employeeId })
toast.promise(promise, {
loading: "Updating primary technician...",
success: "Primary technician updated successfully",
error: "Failed to update primary technician",
})
return promise
},
onSuccess: () => {
setPrimaryTechnicianDialogOpen(false)
router.refresh()
},
})
return ( return (
@ -226,7 +253,7 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
icon={UserCheck} icon={UserCheck}
label="Primary Technician" label="Primary Technician"
value={primaryTechnicianName ?? null} value={primaryTechnicianName ?? null}
isPending={false} isPending={changePrimaryTechnicianMutation.isPending}
/> />
</button> </button>
@ -239,7 +266,10 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
title="Change Service Writer" title="Change Service Writer"
description="Search and select an employee to assign as service writer." description="Search and select an employee to assign as service writer."
isPending={changeServiceWriterMutation.isPending} isPending={changeServiceWriterMutation.isPending}
onSelect={(id) => changeServiceWriterMutation.mutate(id)} onSelect={(id) => {
console.log('Service Writer dialog onSelect called with:', id, 'Type:', typeof id)
changeServiceWriterMutation.mutate(id)
}}
/> />
<EmployeePickerDialog <EmployeePickerDialog
@ -248,24 +278,22 @@ export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesP
title="Change Sales Person" title="Change Sales Person"
description="Search and select an employee to assign as sales person." description="Search and select an employee to assign as sales person."
isPending={changeSalesPersonMutation.isPending} isPending={changeSalesPersonMutation.isPending}
onSelect={(id) => changeSalesPersonMutation.mutate(id)} onSelect={(id) => {
/> console.log('Dialog onSelect called with:', id, 'Type:', typeof id)
<EmployeePickerDialog changeSalesPersonMutation.mutate(id)
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 <EmployeePickerDialog
open={primaryTechnicianDialogOpen} open={primaryTechnicianDialogOpen}
onOpenChange={setPrimaryTechnicianDialogOpen} onOpenChange={setPrimaryTechnicianDialogOpen}
title="Change Primary Technician" title="Change Primary Technician"
description="Search and select an employee to assign as primary technician." description="Search and select an employee to assign as primary technician."
isPending={false} isPending={changePrimaryTechnicianMutation.isPending}
onSelect={()=>{}} onSelect={(id) => {
// onSelect={(id) => changePrimaryTechnicianMutation.mutate(id)} console.log('Primary Technician dialog onSelect called with:', id, 'Type:', typeof id)
changePrimaryTechnicianMutation.mutate(id)
}}
/> />
</div> </div>

View File

@ -69,9 +69,10 @@ const DEFAULT_VALUES: JobCardFormValues = {
check_in_date: "", check_in_date: "",
check_in_time: "", check_in_time: "",
start_date: "", start_date: "",
start_time: "", // Must be initialized with 00:00:00
start_time: "00:00:00",
delivery_date: "", delivery_date: "",
delivery_time: "", delivery_time: "00:00:00",
km_in: "", km_in: "",
fuel_level: "", fuel_level: "",
has_insurance: false, has_insurance: false,

View File

@ -79,7 +79,7 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<JobCardActions <JobCardActions
jobCardId={String(jobCard)} jobCardId={String(jobCard.id )}
orderDate={jobCard.order_date ?? null} orderDate={jobCard.order_date ?? null}
serviceWriterName={jobCard.service_writer?.first_name} serviceWriterName={jobCard.service_writer?.first_name}
salesPersonName={jobCard.sales_person?.first_name} salesPersonName={jobCard.sales_person?.first_name}

View File

@ -47,6 +47,7 @@ export default function JobCardPaymentsReceived() {
<PaymentReceivedForm <PaymentReceivedForm
resourceId={resourceId} resourceId={resourceId}
defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }} defaultJobCard={{ id: jobCard?.id, title: jobCard?.title }}
invoiceCustomer={jobCard?.customer as any}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
/> />
)} )}
@ -83,14 +84,14 @@ export default function JobCardPaymentsReceived() {
}, },
}, },
{ {
accessorKey: "payment_mode_name", accessorKey: "payment_mode",
header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />, header: ({ column }) => <ColumnHeader column={column} title="Payment Mode" />,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original as any const item = row.original as any
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CreditCardIcon className="h-4 w-4 text-muted-foreground" /> <CreditCardIcon className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{item.payment_mode_name || "—"}</span> <span className="capitalize">{(item.payment_mode?.title) || "—"}</span>
</div> </div>
) )
}, },

View File

@ -100,12 +100,10 @@ export function PaymentReceivedForm({ resourceId, initialData, onSuccess, defaul
base.job_card = toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined) base.job_card = toRelation(defaultJobCard.id, defaultJobCard.title ?? undefined)
} }
if (invoiceCustomer?.id != null) { if (invoiceCustomer?.id != null) {
base.customer = toRelation( const customerLabel = invoiceCustomer.first_name
invoiceCustomer.id, ? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim()
invoiceCustomer.first_name : (invoiceCustomer as any).company_name || (invoiceCustomer as any).name || undefined
? `${invoiceCustomer.first_name} ${invoiceCustomer.last_name || ""}`.trim() base.customer = toRelation(invoiceCustomer.id, customerLabel)
: undefined
)
} }
if (invoiceAmount != null && invoiceAmount !== "") { if (invoiceAmount != null && invoiceAmount !== "") {
base.amount_received = String(invoiceAmount) base.amount_received = String(invoiceAmount)

View File

@ -131,7 +131,7 @@ export function RhfVehicleSelectField<
const combobox = ( const combobox = (
<div ref={anchorRef}> <div ref={anchorRef}>
<Combobox <Combobox
value={field.value} value={field.value}
onValueChange={(val: VehicleOption | VehicleOption[] | null) => { onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val const single = Array.isArray(val) ? val[0] ?? null : val
@ -246,7 +246,7 @@ export function RhfVehicleSelectField<
<DialogTitle className="text-2xl font-bold"> <DialogTitle className="text-2xl font-bold">
Add {label} Add {label}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<ScrollArea className="max-h-[80vh] px-4"> <ScrollArea className="max-h-[80vh] px-4">
<VehicleForm onSuccess={handleCreateSuccess} /> <VehicleForm onSuccess={handleCreateSuccess} />
</ScrollArea> </ScrollArea>

View File

@ -23,6 +23,7 @@ import { useAuthApi } from "@/shared/useApi"
import { useResourceForm } from "@/shared/hooks/use-resource-form" import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation" import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toRelation, toId } from "@/shared/lib/utils" import { toRelation, toId } from "@/shared/lib/utils"
import { formatUppercase } from "@/shared/utils/formatters"
import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema" import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
import { VEHICLE_ROUTES } from "@garage/api" import { VEHICLE_ROUTES } from "@garage/api"
@ -225,7 +226,12 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
{/* License & identifiers */} {/* License & identifiers */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="license_plate" label="License Plate" placeholder="e.g. ABC-123" /> <RhfTextField
name="license_plate"
label="License Plate"
placeholder="e.g. ABC-123"
formatter={formatUppercase}
/>
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" /> <RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
</div> </div>

View File

@ -131,7 +131,7 @@ export function CrudDialog<TClient extends CrudDialogClient>({
Add {title} Add {title}
</Button> </Button>
</div> </div>
<DataTable <DataTable
columns={allColumns} columns={allColumns}
data={crud.items} data={crud.items}
pagination={crud.pagination} pagination={crud.pagination}

View File

@ -21,6 +21,42 @@ export type UseCrudDialogOptions<TClient extends CrudDialogClient> = {
resourceLabel?: string resourceLabel?: string
} }
type CrudListShape = {
data?: unknown
meta?: {
last_page?: number
total?: number
}
pagination?: {
last_page?: number
total?: number
}
}
function normalizeCrudListResponse(response: unknown): {
items: any[]
meta?: { last_page?: number; total?: number }
} {
const root = (response ?? {}) as CrudListShape
const directData = root.data
if (Array.isArray(directData)) {
return { items: directData, meta: root.meta ?? root.pagination }
}
if (directData && typeof directData === "object") {
const nested = directData as CrudListShape
if (Array.isArray(nested.data)) {
return {
items: nested.data,
meta: nested.meta ?? nested.pagination ?? root.meta ?? root.pagination,
}
}
}
return { items: [], meta: root.meta ?? root.pagination }
}
// ── Hook ── // ── Hook ──
export function useCrudDialog<TClient extends CrudDialogClient>({ export function useCrudDialog<TClient extends CrudDialogClient>({
@ -46,17 +82,23 @@ export function useCrudDialog<TClient extends CrudDialogClient>({
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: fullQueryKey, queryKey: fullQueryKey,
queryFn: () => { queryFn: async () => {
const params: Record<string, unknown> = { page, per_page: pageSize } const params: Record<string, unknown> = { page, per_page: pageSize }
if (sortBy) params.sort_by = sortBy if (sortBy) params.sort_by = sortBy
if (sortOrder) params.sort_order = sortOrder if (sortOrder) params.sort_order = sortOrder
return client.list(params)
try {
return await client.list(params)
} catch {
// Some endpoints ignore/reject pagination params; retry without params.
return client.list()
}
}, },
}) })
const responseData = (data as any)?.data ?? [] const normalized = normalizeCrudListResponse(data)
const items = Array.isArray(responseData) ? responseData : [] const items = normalized.items
const meta = (data as any)?.meta const meta = normalized.meta
const pagination: DataViewPaginationState = { const pagination: DataViewPaginationState = {
page, page,

View File

@ -5,6 +5,7 @@ export type TextInputFieldProps = BaseFieldControlProps<string> & {
placeholder?: string placeholder?: string
type?: React.HTMLInputTypeAttribute type?: React.HTMLInputTypeAttribute
step?: React.InputHTMLAttributes<HTMLInputElement>["step"] step?: React.InputHTMLAttributes<HTMLInputElement>["step"]
formatter?: (value: string) => string
} }
export function TextInputField({ export function TextInputField({
@ -17,11 +18,12 @@ export function TextInputField({
placeholder, placeholder,
type = "text", type = "text",
step, step,
formatter,
}: TextInputFieldProps) { }: TextInputFieldProps) {
return ( return (
<Input <Input
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(formatter ? formatter(e.target.value) : e.target.value)}
onBlur={onBlur} onBlur={onBlur}
name={name} name={name}
disabled={disabled} disabled={disabled}

View File

@ -38,7 +38,7 @@ export function TimePickerField({
disabled, disabled,
invalid, invalid,
placeholder = "Pick a time", placeholder = "Pick a time",
withSeconds = true, withSeconds = false,
}: TimePickerFieldProps) { }: TimePickerFieldProps) {
const { hours, minutes, seconds } = parseTime(value ?? "") const { hours, minutes, seconds } = parseTime(value ?? "")
const hasValue = !!value const hasValue = !!value

View File

@ -20,5 +20,5 @@ export function RhfTimeField<
TValues extends FieldValues, TValues extends FieldValues,
TName extends FieldPath<TValues>, TName extends FieldPath<TValues>,
>(props: RhfTimeFieldProps<TValues, TName>) { >(props: RhfTimeFieldProps<TValues, TName>) {
return <RhfField {...props} component={TimePickerField} /> return <RhfField {...props} component={TimePickerField} />
} }

View File

@ -99,3 +99,11 @@ export function formatCurrency(
if (isNaN(num)) return "—" if (isNaN(num)) return "—"
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num) return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num)
} }
/**
* Format text to uppercase.
*/
export function formatUppercase(value?: string | null): string {
if (value == null) return ""
return value.toUpperCase()
}

View File

@ -20,6 +20,7 @@ export const JOB_CARD_ROUTES = {
DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment", DELETE_ATTACHMENT: "/api/job-cards/{id}/delete-attachment",
CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id", CHANGE_SERVICE_WRITER: "/api/job-cards/{id}/change-service-writer-id",
CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id", CHANGE_SALES_PERSON: "/api/job-cards/{id}/change-sales-person-id",
CHANGE_TECHNICIAN: "/api/job-cards/{id}/change-technician-id",
GET_PARTS: "/api/job-cards/{id}/get-parts", GET_PARTS: "/api/job-cards/{id}/get-parts",
ADD_PART: "/api/job-cards/{id}/add-part", ADD_PART: "/api/job-cards/{id}/add-part",
UPDATE_PART: "/api/job-cards/{id}/update-part", UPDATE_PART: "/api/job-cards/{id}/update-part",
@ -118,6 +119,10 @@ export class JobCardsClient extends CrudClient<
return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } }) return this.post(JOB_CARD_ROUTES.CHANGE_SALES_PERSON, payload, { params: { id } })
} }
async changeTechnician(id: string, payload: ApiRequestBody<typeof JOB_CARD_ROUTES.CHANGE_TECHNICIAN, "post">) {
return this.post(JOB_CARD_ROUTES.CHANGE_TECHNICIAN, payload, { params: { id } })
}
async getParts(id: string, params?: Record<string, unknown>) { async getParts(id: string, params?: Record<string, unknown>) {
return this.get(JOB_CARD_ROUTES.GET_PARTS, { params: { id }, query: params as any }) return this.get(JOB_CARD_ROUTES.GET_PARTS, { params: { id }, query: params as any })
} }