updates
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,39 +11,55 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover"
|
||||
import { Calendar } from "@/shared/components/ui/calendar"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { toast } from "sonner"
|
||||
import { Ellipsis, Pencil, Trash2, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
|
||||
import { Pencil, CalendarIcon, UserCog, UserCheck, Loader2 } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
import { EmployeeCombobox } from "@/modules/employees/employee-combobox"
|
||||
|
||||
type JobCardActionsProps = {
|
||||
jobCardId: string
|
||||
orderDate?: string | null
|
||||
serviceWriterName?: string | null
|
||||
salesPersonName?: string | null
|
||||
}
|
||||
|
||||
type Employee = {
|
||||
id: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
name?: string
|
||||
}
|
||||
// ── Informative Action Card ──
|
||||
|
||||
function getEmployeeName(emp: Employee) {
|
||||
return emp.name || [emp.first_name, emp.last_name].filter(Boolean).join(" ") || `Employee #${emp.id}`
|
||||
function ActionCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
isPending,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
isPending?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer select-none">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
<span className="text-[11px] font-medium text-muted-foreground leading-none">{label}</span>
|
||||
<span className="text-sm font-semibold leading-tight truncate">
|
||||
{value ?? <span className="font-normal text-muted-foreground italic">Not set</span>}
|
||||
</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Loader2 className="ml-2 size-3.5 shrink-0 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Pencil className="ml-2 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Employee Picker Dialog ──
|
||||
@@ -61,8 +69,6 @@ type EmployeePickerDialogProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
employees: Employee[]
|
||||
loading: boolean
|
||||
isPending: boolean
|
||||
onSelect: (employeeId: number) => void
|
||||
}
|
||||
@@ -72,18 +78,9 @@ function EmployeePickerDialog({
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
employees,
|
||||
loading,
|
||||
isPending,
|
||||
onSelect,
|
||||
}: EmployeePickerDialogProps) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSelect = (emp: Employee | null) => {
|
||||
if (!emp) return
|
||||
onSelect(emp.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -91,36 +88,15 @@ function EmployeePickerDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={null}
|
||||
onValueChange={handleSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search employees..."
|
||||
showClear={false}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
employees.map((emp) => (
|
||||
<ComboboxItem key={emp.id} value={emp}>
|
||||
{getEmployeeName(emp)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{!loading && employees.length === 0 && (
|
||||
<ComboboxEmpty>No employees found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
<EmployeeCombobox
|
||||
value={null}
|
||||
onValueChange={(emp) => {
|
||||
if (emp) onSelect(Number(emp.value))
|
||||
}}
|
||||
disabled={isPending}
|
||||
placeholder="Search employees..."
|
||||
showClear={false}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -128,42 +104,13 @@ function EmployeePickerDialog({
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
export function JobCardActions({ jobCardId, orderDate, serviceWriterName, salesPersonName }: JobCardActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [serviceWriterDialogOpen, setServiceWriterDialogOpen] = useState(false)
|
||||
const [salesPersonDialogOpen, setSalesPersonDialogOpen] = useState(false)
|
||||
|
||||
const { data: employeesData, isLoading: employeesLoading } = useQuery({
|
||||
queryKey: [EMPLOYEE_ROUTES.INDEX],
|
||||
queryFn: () => api.employees.list(),
|
||||
})
|
||||
|
||||
const employees: Employee[] = (employeesData as any)?.data ?? []
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${jobCardId}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
description: "Are you sure you want to delete this job card? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
const promise = api.jobCards.destroy(jobCardId)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting job card...",
|
||||
success: "Job card deleted successfully",
|
||||
error: "Failed to delete job card",
|
||||
})
|
||||
await promise
|
||||
router.push("/sales/job-cards")
|
||||
}
|
||||
}
|
||||
|
||||
const changeDateMutation = useMutation({
|
||||
mutationFn: (date: Date) => {
|
||||
@@ -215,15 +162,20 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
{/* Check-in Date Action Card */}
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<CalendarIcon className="size-4" />
|
||||
<span className="hidden sm:inline">Change Date</span>
|
||||
</Button>
|
||||
<button type="button" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg">
|
||||
<ActionCard
|
||||
icon={CalendarIcon}
|
||||
label="Order Date"
|
||||
value={orderDate ? new Date(orderDate).toLocaleDateString() : null}
|
||||
isPending={changeDateMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
@@ -234,42 +186,42 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setServiceWriterDialogOpen(true)}>
|
||||
<UserCog className="size-4" />
|
||||
<span className="hidden sm:inline">Service Writer</span>
|
||||
</Button>
|
||||
{/* Service Writer Action Card */}
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||
onClick={() => setServiceWriterDialogOpen(true)}
|
||||
>
|
||||
<ActionCard
|
||||
icon={UserCog}
|
||||
label="Service Writer"
|
||||
value={serviceWriterName ?? null}
|
||||
isPending={changeServiceWriterMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setSalesPersonDialogOpen(true)}>
|
||||
<UserCheck className="size-4" />
|
||||
<span className="hidden sm:inline">Sales Person</span>
|
||||
</Button>
|
||||
{/* Sales Person Action Card */}
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
|
||||
onClick={() => setSalesPersonDialogOpen(true)}
|
||||
>
|
||||
<ActionCard
|
||||
icon={UserCheck}
|
||||
label="Sales Person"
|
||||
value={salesPersonName ?? null}
|
||||
isPending={changeSalesPersonMutation.isPending}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Edit / Delete Dropdown */}
|
||||
|
||||
|
||||
<EmployeePickerDialog
|
||||
open={serviceWriterDialogOpen}
|
||||
onOpenChange={setServiceWriterDialogOpen}
|
||||
title="Change Service Writer"
|
||||
description="Search and select an employee to assign as service writer."
|
||||
employees={employees}
|
||||
loading={employeesLoading}
|
||||
isPending={changeServiceWriterMutation.isPending}
|
||||
onSelect={(id) => changeServiceWriterMutation.mutate(id)}
|
||||
/>
|
||||
@@ -279,8 +231,6 @@ export function JobCardActions({ jobCardId }: JobCardActionsProps) {
|
||||
onOpenChange={setSalesPersonDialogOpen}
|
||||
title="Change Sales Person"
|
||||
description="Search and select an employee to assign as sales person."
|
||||
employees={employees}
|
||||
loading={employeesLoading}
|
||||
isPending={changeSalesPersonMutation.isPending}
|
||||
onSelect={(id) => changeSalesPersonMutation.mutate(id)}
|
||||
/>
|
||||
|
||||
@@ -2,31 +2,26 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react"
|
||||
import type { JobCardStatus } from "./job-card.schema"
|
||||
|
||||
type JobCardContextValue = {
|
||||
id: string
|
||||
label: string
|
||||
status: JobCardStatus
|
||||
setStatus: (status: JobCardStatus) => void
|
||||
}
|
||||
|
||||
const JobCardContext = createContext<JobCardContextValue | null>(null)
|
||||
import { JobCardResponseData } from "@garage/api"
|
||||
|
||||
|
||||
const JobCardContext = createContext<JobCardResponseData | null>(null)
|
||||
|
||||
export function JobCardProvider({
|
||||
jobCard,
|
||||
children,
|
||||
}: {
|
||||
jobCard: { id: string; label: string; status: JobCardStatus }
|
||||
jobCard: JobCardResponseData
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status)
|
||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status as JobCardStatus)
|
||||
|
||||
const setStatus = useCallback((newStatus: JobCardStatus) => {
|
||||
setStatusState(newStatus)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<JobCardContext.Provider value={{ id: jobCard.id, label: jobCard.label, status, setStatus }}>
|
||||
<JobCardContext.Provider value={{ ...jobCard, status, setStatus } as JobCardResponseData}>
|
||||
{children}
|
||||
</JobCardContext.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
"use client"
|
||||
import { confirm } from '@/shared/components/confirm-dialog';
|
||||
import { api } from '@garage/api';
|
||||
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';
|
||||
export default function JobCardDropdown({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/job-cards/${id}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete Job Card",
|
||||
description: "Are you sure you want to delete this job card? This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) {
|
||||
const promise = api.jobCards.destroy(id)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting job card...",
|
||||
success: "Job card deleted successfully",
|
||||
error: "Failed to delete job card",
|
||||
})
|
||||
await promise
|
||||
router.push("/sales/job-cards")
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="self-stretch h-auto aspect-square">
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDateField,
|
||||
RhfTimeField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@@ -23,8 +25,13 @@ import {
|
||||
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, CUSTOMER_ROUTES, VEHICLE_ROUTES } from "@garage/api"
|
||||
import { JOB_CARD_ROUTES, EMPLOYEE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
|
||||
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
|
||||
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
@@ -40,10 +47,17 @@ const DEFAULT_VALUES: JobCardFormValues = {
|
||||
title: "",
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
status: "draft",
|
||||
department: null,
|
||||
service_writer: null,
|
||||
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")}` })(),
|
||||
km_in: "",
|
||||
fuel_level: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@@ -53,12 +67,19 @@ function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
|
||||
return {
|
||||
title: d.title || "",
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
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),
|
||||
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),
|
||||
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] : "",
|
||||
km_in: d.km_in != null ? String(d.km_in) : "",
|
||||
fuel_level: d.fuel_level || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +88,17 @@ function mapFormToPayload(values: JobCardFormValues) {
|
||||
title: values.title,
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
department_id: toId(values.department),
|
||||
service_writer_id: toId(values.service_writer),
|
||||
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,
|
||||
check_in_date: values.check_in_date || undefined,
|
||||
check_in_time: values.check_in_time || undefined,
|
||||
km_in: values.km_in ? Number(values.km_in) : undefined,
|
||||
fuel_level: values.fuel_level || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +109,9 @@ const mapLookupOption = (item: any) => ({
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const mapCustomerOption = (item: any) => ({
|
||||
const mapEmployeeOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.first_name, item.last_name].filter(Boolean).join(" "),
|
||||
})
|
||||
|
||||
const mapVehicleOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: [item.year, item.make_name, item.model_name].filter(Boolean).join(" "),
|
||||
label: `${item.first_name} ${item.last_name}`,
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
@@ -141,27 +164,62 @@ export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormP
|
||||
<FieldGroup>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* <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="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={[CUSTOMER_ROUTES.INDEX]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={mapCustomerOption}
|
||||
name="department"
|
||||
label="Department"
|
||||
placeholder="Select department"
|
||||
queryKey={[DEPARTMENT_ROUTES.INDEX]}
|
||||
listFn={() => api.departments.list()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
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"
|
||||
|
||||
@@ -20,37 +20,15 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { JobCardActions } from "./job-card-actions"
|
||||
import { getVehicleLabel } from "../vehicles/utils/getVehicleLabel"
|
||||
import { JobCardResponseData } from "@garage/api"
|
||||
import { getFullName } from "@/shared/utils/getFullName"
|
||||
|
||||
type JobCardData = {
|
||||
id?: number
|
||||
title?: string
|
||||
status?: string
|
||||
check_in_date?: string
|
||||
km_in?: number
|
||||
tax_inclusive?: string
|
||||
discount_type?: string
|
||||
discount_at?: string
|
||||
customer_id?: number
|
||||
customer_name?: string
|
||||
vehicle_id?: number
|
||||
vehicle_name?: string
|
||||
department_id?: number
|
||||
department_name?: string
|
||||
sales_person_id?: number
|
||||
sales_person_name?: string
|
||||
service_writer_id?: number
|
||||
service_writer_name?: string
|
||||
purchase_orders_count?: number
|
||||
bills_count?: number
|
||||
expenses_count?: number
|
||||
tasks_count?: number
|
||||
appointments_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
|
||||
type JobCardGeneralInfoProps = {
|
||||
jobCard: JobCardData
|
||||
jobCard: JobCardResponseData
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
@@ -86,7 +64,7 @@ const statusColorMap: Record<string, string> = {
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCardResponseData }) {
|
||||
const formatStatus = (status?: string) => {
|
||||
if (!status) return null
|
||||
return status
|
||||
@@ -95,9 +73,17 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
console.log(jobCard)
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Job Card Details */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{jobCard.service_writer?.first_name}
|
||||
<JobCardActions
|
||||
jobCardId={String(jobCard.id)}
|
||||
orderDate={jobCard.order_date ?? null}
|
||||
serviceWriterName={jobCard.service_writer?.first_name}
|
||||
salesPersonName={jobCard.sales_person?.first_name}
|
||||
/>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -155,27 +141,27 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
<InfoItem
|
||||
icon={Users}
|
||||
label="Customer"
|
||||
value={jobCard.customer_name}
|
||||
value={getFullName(jobCard.customer)}
|
||||
/>
|
||||
<InfoItem
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={jobCard.vehicle_name}
|
||||
value={getVehicleLabel(jobCard.vehicle as any)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={jobCard.department_name}
|
||||
value={jobCard.department as any}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Briefcase}
|
||||
label="Sales Person"
|
||||
value={jobCard.sales_person_name}
|
||||
value={getFullName(jobCard.sales_person as any)}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={UserCheck}
|
||||
label="Service Writer"
|
||||
value={jobCard.service_writer_name}
|
||||
value={getFullName(jobCard.service_writer as any)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -244,6 +230,7 @@ export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0 overflow-x-auto">
|
||||
{JOB_CARD_STATUSES.map((step, index) => {
|
||||
const Icon = STATUS_ICONS[step.value]
|
||||
const Icon = STATUS_ICONS[step.value] ?? CircleDot
|
||||
const isActive = step.value === currentStatus
|
||||
const isCompleted = index < currentIndex
|
||||
const isTransitioning = isPending && variables === step.value
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { z } from "zod"
|
||||
import {
|
||||
JobCardStatus,
|
||||
EstimateTo,
|
||||
TaxInclusive,
|
||||
DiscountType,
|
||||
DiscountAt,
|
||||
FuelLevel,
|
||||
} from "@garage/api"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
@@ -6,31 +14,37 @@ const relationFieldSchema = z
|
||||
|
||||
// ── Job Card Statuses ──
|
||||
|
||||
export const JOB_CARD_STATUSES = [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "check_in", label: "Check In" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "on_hold", label: "On Hold" },
|
||||
{ value: "ready_to_deliver", label: "Ready to Deliver" },
|
||||
{ value: "delivered", label: "Delivered" },
|
||||
] as const
|
||||
export const JOB_CARD_STATUS_OPTIONS = JobCardStatus.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
export type JobCardStatus = (typeof JOB_CARD_STATUSES)[number]["value"]
|
||||
const ESTIMATE_TO_OPTIONS = EstimateTo.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = [
|
||||
{ value: "Tax Inclusive", label: "Tax Inclusive" },
|
||||
{ value: "Tax Exclusive", label: "Tax Exclusive" },
|
||||
]
|
||||
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
|
||||
|
||||
const DISCOUNT_TYPE_OPTIONS = [
|
||||
{ value: "no", label: "No Discount" },
|
||||
{ value: "transaction_level", label: "Transaction Level" },
|
||||
]
|
||||
const DISCOUNT_TYPE_OPTIONS = DiscountType.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const DISCOUNT_AT_OPTIONS = [
|
||||
{ value: "inclusive_of_tax", label: "Inclusive of Tax" },
|
||||
{ value: "exclusive_of_tax", label: "Exclusive of Tax" },
|
||||
]
|
||||
const DISCOUNT_AT_OPTIONS = DiscountAt.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const FUEL_LEVEL_OPTIONS = FuelLevel.map((v) => ({
|
||||
value: v,
|
||||
label: v
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const jobCardFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
@@ -39,15 +53,36 @@ const jobCardFormSchema = z.object({
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
department: relationFieldSchema,
|
||||
service_writer: relationFieldSchema,
|
||||
|
||||
// ── 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 ──
|
||||
check_in_date: z.string().optional(),
|
||||
check_in_time: z.string().optional(),
|
||||
km_in: z.string().optional(),
|
||||
fuel_level: z.string().optional(),
|
||||
})
|
||||
|
||||
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
||||
|
||||
export { jobCardFormSchema, relationFieldSchema, TAX_INCLUSIVE_OPTIONS, DISCOUNT_TYPE_OPTIONS, DISCOUNT_AT_OPTIONS }
|
||||
export {
|
||||
jobCardFormSchema,
|
||||
relationFieldSchema,
|
||||
TAX_INCLUSIVE_OPTIONS,
|
||||
DISCOUNT_TYPE_OPTIONS,
|
||||
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
|
||||
export type { JobCardFormValues }
|
||||
export type { JobCardStatus } from "@garage/api"
|
||||
|
||||
Reference in New Issue
Block a user