updates
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation, useQuery } 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,
|
||||
DialogHeader,
|
||||
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 { format } from "date-fns"
|
||||
import { EMPLOYEE_ROUTES } from "@garage/api"
|
||||
|
||||
type JobCardActionsProps = {
|
||||
jobCardId: string
|
||||
}
|
||||
|
||||
type Employee = {
|
||||
id: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function getEmployeeName(emp: Employee) {
|
||||
return emp.name || [emp.first_name, emp.last_name].filter(Boolean).join(" ") || `Employee #${emp.id}`
|
||||
}
|
||||
|
||||
// ── Employee Picker Dialog ──
|
||||
|
||||
type EmployeePickerDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
employees: Employee[]
|
||||
loading: boolean
|
||||
isPending: boolean
|
||||
onSelect: (employeeId: number) => void
|
||||
}
|
||||
|
||||
function EmployeePickerDialog({
|
||||
open,
|
||||
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">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function JobCardActions({ jobCardId }: 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) => {
|
||||
const order_date = format(date, "yyyy-MM-dd")
|
||||
const promise = api.jobCards.changeDate(jobCardId, { order_date })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating date...",
|
||||
success: "Date updated successfully",
|
||||
error: "Failed to update date",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
setDatePickerOpen(false)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const changeServiceWriterMutation = useMutation({
|
||||
mutationFn: (employeeId: number) => {
|
||||
const promise = api.jobCards.changeServiceWriter(jobCardId, { service_writer_id: employeeId })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating service writer...",
|
||||
success: "Service writer updated successfully",
|
||||
error: "Failed to update service writer",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
setServiceWriterDialogOpen(false)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
const changeSalesPersonMutation = useMutation({
|
||||
mutationFn: (employeeId: number) => {
|
||||
const promise = api.jobCards.changeSalesPerson(jobCardId, { sales_person_id: employeeId })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating sales person...",
|
||||
success: "Sales person updated successfully",
|
||||
error: "Failed to update sales person",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSalesPersonDialogOpen(false)
|
||||
router.refresh()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
if (date) changeDateMutation.mutate(date)
|
||||
}}
|
||||
disabled={changeDateMutation.isPending}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setServiceWriterDialogOpen(true)}>
|
||||
<UserCog className="size-4" />
|
||||
<span className="hidden sm:inline">Service Writer</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setSalesPersonDialogOpen(true)}>
|
||||
<UserCheck className="size-4" />
|
||||
<span className="hidden sm:inline">Sales Person</span>
|
||||
</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>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
<EmployeePickerDialog
|
||||
open={salesPersonDialogOpen}
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
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)
|
||||
|
||||
export function JobCardProvider({
|
||||
jobCard,
|
||||
children,
|
||||
}: {
|
||||
jobCard: { id: string; label: string; status: JobCardStatus }
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [status, setStatusState] = useState<JobCardStatus>(jobCard.status)
|
||||
|
||||
const setStatus = useCallback((newStatus: JobCardStatus) => {
|
||||
setStatusState(newStatus)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<JobCardContext.Provider value={{ id: jobCard.id, label: jobCard.label, status, setStatus }}>
|
||||
{children}
|
||||
</JobCardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useJobCard() {
|
||||
return useContext(JobCardContext)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
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,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
jobCardFormSchema,
|
||||
type JobCardFormValues,
|
||||
TAX_INCLUSIVE_OPTIONS,
|
||||
DISCOUNT_TYPE_OPTIONS,
|
||||
DISCOUNT_AT_OPTIONS,
|
||||
} from "./job-card.schema"
|
||||
import { JOB_CARD_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type JobCardFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: JobCardFormValues = {
|
||||
title: "",
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
status: "draft",
|
||||
tax_inclusive: "Tax Inclusive",
|
||||
discount_type: "no",
|
||||
discount_at: "inclusive_of_tax",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): JobCardFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
return {
|
||||
title: d.title || "",
|
||||
customer: toRelation(d.customer_id, d.customer_name),
|
||||
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
|
||||
status: d.status || "draft",
|
||||
tax_inclusive: d.tax_inclusive || "Tax Inclusive",
|
||||
discount_type: d.discount_type || "no",
|
||||
discount_at: d.discount_at || "inclusive_of_tax",
|
||||
}
|
||||
}
|
||||
|
||||
function mapFormToPayload(values: JobCardFormValues) {
|
||||
return {
|
||||
title: values.title,
|
||||
customer_id: toId(values.customer),
|
||||
vehicle_id: toId(values.vehicle),
|
||||
status: values.status || undefined,
|
||||
tax_inclusive: values.tax_inclusive || undefined,
|
||||
discount_type: values.discount_type || undefined,
|
||||
discount_at: values.discount_at || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})
|
||||
|
||||
const mapCustomerOption = (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(" "),
|
||||
})
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function JobCardForm({ resourceId, initialData, onSuccess }: JobCardFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<JobCardFormValues, any>({
|
||||
schema: jobCardFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
queryKey: [JOB_CARD_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: JobCardFormValues) => {
|
||||
const payload = mapFormToPayload(values)
|
||||
const promise = (isEditing && resourceId
|
||||
? api.jobCards.update(resourceId, payload)
|
||||
: api.jobCards.create(payload)) as Promise<any>
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating job card..." : "Creating job card...",
|
||||
success: isEditing ? "Job card updated successfully" : "Job card created successfully",
|
||||
error: isEditing ? "Failed to update job card" : "Failed to create job card",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{isEditing ? "Failed to update job card" : "Failed to create job card"}
|
||||
</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfTextField name="title" label="Title" placeholder="Job Card 001" required />
|
||||
|
||||
<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}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfAsyncSelectField
|
||||
name="vehicle"
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
queryKey={[VEHICLE_ROUTES.INDEX]}
|
||||
listFn={() => api.vehicles.list()}
|
||||
mapOption={mapVehicleOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending
|
||||
? (isEditing ? "Updating..." : "Creating...")
|
||||
: (isEditing ? "Update Job Card" : "Create Job Card")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
ClipboardList,
|
||||
Calendar,
|
||||
Hash,
|
||||
Users,
|
||||
Car,
|
||||
Building2,
|
||||
Gauge,
|
||||
Clock,
|
||||
UserCheck,
|
||||
Briefcase,
|
||||
Receipt,
|
||||
DollarSign,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{value || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
draft: "secondary",
|
||||
check_in: "default",
|
||||
in_progress: "default",
|
||||
completed: "default",
|
||||
invoiced: "outline",
|
||||
cancelled: "destructive",
|
||||
}
|
||||
|
||||
export function JobCardGeneralInfo({ jobCard }: JobCardGeneralInfoProps) {
|
||||
const formatStatus = (status?: string) => {
|
||||
if (!status) return null
|
||||
return status
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Job Card Details */}
|
||||
<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={jobCard.customer_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Car}
|
||||
label="Vehicle"
|
||||
value={jobCard.vehicle_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Building2}
|
||||
label="Department"
|
||||
value={jobCard.department_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={Briefcase}
|
||||
label="Sales Person"
|
||||
value={jobCard.sales_person_name}
|
||||
/>
|
||||
<InfoItem
|
||||
icon={UserCheck}
|
||||
label="Service Writer"
|
||||
value={jobCard.service_writer_name}
|
||||
/>
|
||||
</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={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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextareaField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const schema = z.object({
|
||||
recommendation: z.string().min(1, "Recommendation is required"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
type JobCardRecommendationFormProps = {
|
||||
jobCardId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function JobCardRecommendationForm({ jobCardId, onSuccess }: JobCardRecommendationFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { recommendation: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await api.jobCards.addShopRecommendation(jobCardId, {
|
||||
recommendation: values.recommendation,
|
||||
})
|
||||
toast.success("Shop recommendation added")
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
toast.error("Failed to add shop recommendation")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextareaField
|
||||
name="recommendation"
|
||||
label="Shop Recommendation"
|
||||
placeholder="Enter shop recommendation..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Adding..." : "Add Recommendation"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Rhform, RhfTextareaField } from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const schema = z.object({
|
||||
remark: z.string().min(1, "Remark is required"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
type JobCardRemarkFormProps = {
|
||||
jobCardId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function JobCardRemarkForm({ jobCardId, onSuccess }: JobCardRemarkFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { remark: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await api.jobCards.addCustomerRemark(jobCardId, {
|
||||
remark: values.remark,
|
||||
})
|
||||
toast.success("Customer remark added")
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
} catch {
|
||||
toast.error("Failed to add customer remark")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextareaField
|
||||
name="remark"
|
||||
label="Customer Remark"
|
||||
placeholder="Enter customer remark..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Plus />
|
||||
{form.formState.isSubmitting ? "Adding..." : "Add Remark"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useJobCard } from "./job-card-context"
|
||||
import { JOB_CARD_STATUSES, type JobCardStatus } from "./job-card.schema"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip"
|
||||
import {
|
||||
CircleDot,
|
||||
LogIn,
|
||||
Loader,
|
||||
Pause,
|
||||
PackageCheck,
|
||||
CheckCircle2,
|
||||
} from "lucide-react"
|
||||
|
||||
// ── Status icon mapping ──
|
||||
|
||||
const STATUS_ICONS: Record<JobCardStatus, React.ComponentType<{ className?: string }>> = {
|
||||
draft: CircleDot,
|
||||
check_in: LogIn,
|
||||
in_progress: Loader,
|
||||
on_hold: Pause,
|
||||
ready_to_deliver: PackageCheck,
|
||||
delivered: CheckCircle2,
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
type JobCardStatusStepperProps = {
|
||||
jobCardId: string
|
||||
}
|
||||
|
||||
export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
|
||||
const api = useAuthApi()
|
||||
const jobCard = useJobCard()
|
||||
const currentStatus = jobCard?.status ?? "draft"
|
||||
|
||||
const currentIndex = JOB_CARD_STATUSES.findIndex((s) => s.value === currentStatus)
|
||||
|
||||
const { mutate, isPending, variables } = useMutation({
|
||||
mutationFn: async (status: JobCardStatus) => {
|
||||
const promise = api.jobCards.changeStatus(jobCardId, { status })
|
||||
toast.promise(promise, {
|
||||
loading: "Updating status...",
|
||||
success: "Status updated successfully",
|
||||
error: "Failed to update status",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: (_data, status) => {
|
||||
jobCard?.setStatus(status)
|
||||
},
|
||||
})
|
||||
|
||||
const handleClick = (status: JobCardStatus, index: number) => {
|
||||
if (isPending) return
|
||||
if (status === currentStatus) return
|
||||
mutate(status)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0 overflow-x-auto">
|
||||
{JOB_CARD_STATUSES.map((step, index) => {
|
||||
const Icon = STATUS_ICONS[step.value]
|
||||
const isActive = step.value === currentStatus
|
||||
const isCompleted = index < currentIndex
|
||||
const isTransitioning = isPending && variables === step.value
|
||||
const isClickable = !isPending && step.value !== currentStatus
|
||||
|
||||
return (
|
||||
<div key={step.value} className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(step.value, index)}
|
||||
disabled={!isClickable}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isActive && "bg-primary text-primary-foreground shadow-sm",
|
||||
isCompleted && !isActive && "bg-primary/10 text-primary hover:bg-primary/20",
|
||||
!isActive && !isCompleted && "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
|
||||
isTransitioning && "animate-pulse",
|
||||
!isClickable && !isActive && "opacity-60",
|
||||
isClickable && "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"size-4 shrink-0",
|
||||
isTransitioning && "animate-spin"
|
||||
)} />
|
||||
<span className="hidden whitespace-nowrap sm:inline">{step.label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isActive ? `Current: ${step.label}` : `Change to ${step.label}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Connector line */}
|
||||
{index < JOB_CARD_STATUSES.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-0.5 w-4 shrink-0 transition-colors",
|
||||
index < currentIndex ? "bg-primary/40" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const relationFieldSchema = z
|
||||
.object({ value: z.string(), label: z.string() })
|
||||
.nullable()
|
||||
|
||||
// ── 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 type JobCardStatus = (typeof JOB_CARD_STATUSES)[number]["value"]
|
||||
|
||||
const TAX_INCLUSIVE_OPTIONS = [
|
||||
{ value: "Tax Inclusive", label: "Tax Inclusive" },
|
||||
{ value: "Tax Exclusive", label: "Tax Exclusive" },
|
||||
]
|
||||
|
||||
const DISCOUNT_TYPE_OPTIONS = [
|
||||
{ value: "no", label: "No Discount" },
|
||||
{ value: "transaction_level", label: "Transaction Level" },
|
||||
]
|
||||
|
||||
const DISCOUNT_AT_OPTIONS = [
|
||||
{ value: "inclusive_of_tax", label: "Inclusive of Tax" },
|
||||
{ value: "exclusive_of_tax", label: "Exclusive of Tax" },
|
||||
]
|
||||
|
||||
const jobCardFormSchema = z.object({
|
||||
// ── Required fields ──
|
||||
title: z.string().min(1, "Title is required"),
|
||||
|
||||
// ── Relations ──
|
||||
customer: relationFieldSchema,
|
||||
vehicle: relationFieldSchema,
|
||||
|
||||
// ── Settings ──
|
||||
status: z.string().optional(),
|
||||
tax_inclusive: z.string().optional(),
|
||||
discount_type: z.string().optional(),
|
||||
discount_at: z.string().optional(),
|
||||
})
|
||||
|
||||
type JobCardFormValues = z.infer<typeof jobCardFormSchema>
|
||||
|
||||
export { jobCardFormSchema, relationFieldSchema, TAX_INCLUSIVE_OPTIONS, DISCOUNT_TYPE_OPTIONS, DISCOUNT_AT_OPTIONS }
|
||||
export type { JobCardFormValues }
|
||||
Reference in New Issue
Block a user