This commit is contained in:
2026-04-06 02:32:47 +03:00
parent 5f3d208158
commit 24a44481a0
152 changed files with 85913 additions and 44338 deletions
@@ -0,0 +1,124 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2, CheckCircle, Unlink } from "lucide-react"
import { toast } from "sonner"
import { useQueryClient } from "@tanstack/react-query"
import { APPOINTMENT_ROUTES } from "@garage/api"
type AppointmentActionsProps = {
appointmentId: string
currentStatus?: string
jobCardId?: number | null
}
export function AppointmentActions({ appointmentId, currentStatus, jobCardId }: AppointmentActionsProps) {
const api = useAuthApi()
const router = useRouter()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const handleEdit = () => {
router.push(`/calendar/appointment/${appointmentId}/edit`)
}
const handleDelete = async () => {
setIsLoading(true)
try {
await api.appointments.destroy(appointmentId)
toast.success("Appointment deleted.")
router.push("/calendar/appointment/list")
} catch {
toast.error("Failed to delete appointment.")
} finally {
setIsLoading(false)
}
}
const handleChangeStatus = async (status: string) => {
setIsLoading(true)
try {
await api.appointments.changeStatus(appointmentId, { status } as any)
toast.success(`Status updated to "${status}".`)
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
router.refresh()
} catch {
toast.error("Failed to update status.")
} finally {
setIsLoading(false)
}
}
const handleUnlinkJobCard = async () => {
if (!jobCardId) return
setIsLoading(true)
try {
await api.appointments.unlinkJobCard(appointmentId, {} as any)
toast.success("Job card unlinked.")
queryClient.invalidateQueries({ queryKey: [APPOINTMENT_ROUTES.INDEX] })
router.refresh()
} catch {
toast.error("Failed to unlink job card.")
} finally {
setIsLoading(false)
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading}>
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
{currentStatus !== "confirmed" && (
<DropdownMenuItem onClick={() => handleChangeStatus("confirmed")}>
<CheckCircle className="size-4" />
Confirm
</DropdownMenuItem>
)}
{currentStatus !== "completed" && (
<DropdownMenuItem onClick={() => handleChangeStatus("completed")}>
<CheckCircle className="size-4" />
Mark Completed
</DropdownMenuItem>
)}
{currentStatus !== "cancelled" && (
<DropdownMenuItem onClick={() => handleChangeStatus("cancelled")}>
<CheckCircle className="size-4" />
Cancel
</DropdownMenuItem>
)}
{jobCardId && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUnlinkJobCard}>
<Unlink className="size-4" />
Unlink Job Card
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type AppointmentContextValue = {
id: string
label: string
}
const AppointmentContext = createContext<AppointmentContextValue | null>(null)
export function AppointmentProvider({
appointment,
children,
}: {
appointment: AppointmentContextValue
children: React.ReactNode
}) {
return (
<AppointmentContext.Provider value={appointment}>
{children}
</AppointmentContext.Provider>
)
}
export function useAppointment() {
return useContext(AppointmentContext)
}
@@ -0,0 +1,208 @@
"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,
RhfTextareaField,
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 {
appointmentFormSchema,
type AppointmentFormValues,
APPOINTMENT_STATUS_OPTIONS,
} from "./appointment.schema"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
// ── Props ──
export type AppointmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: AppointmentFormValues = {
title: "",
date: "",
from_time: "",
to_time: "",
customer: null,
vehicle: null,
service_writer: null,
technician: null,
department: null,
job_card: null,
notes: "",
status: "requested",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): AppointmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title || "",
date: d.date || "",
from_time: d.from_time || "",
to_time: d.to_time || "",
customer: toRelation(d.customer_id, d.customer_name),
vehicle: toRelation(d.vehicle_id, d.vehicle_name),
service_writer: toRelation(d.service_writer_id, d.service_writer_name),
technician: toRelation(d.technician_id, d.technician_name),
department: toRelation(d.department_id, d.department_name),
job_card: toRelation(d.job_card_id, d.job_card_title),
notes: d.notes || "",
status: d.status || "requested",
}
}
function mapFormToPayload(values: AppointmentFormValues) {
return {
title: values.title,
date: values.date,
from_time: values.from_time,
to_time: values.to_time,
customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle),
service_writer_id: toId(values.service_writer),
technician_id: toId(values.technician),
department_id: toId(values.department),
job_card_id: toId(values.job_card),
notes: values.notes || undefined,
status: values.status || undefined,
}
}
// ── Shared helpers ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const mapJobCardOption = (item: any) => ({
value: String(item.id),
label: item.title || `Job Card #${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function AppointmentForm({ resourceId, initialData, onSuccess }: AppointmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<AppointmentFormValues>({
schema: appointmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, isPending, error } = useFormMutation(form, {
mutationFn: (values: AppointmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = (isEditing && resourceId
? api.appointments.update(resourceId, payload as any)
: api.appointments.create(payload as any)) as Promise<any>
toast.promise(promise, {
loading: isEditing ? "Updating appointment..." : "Creating appointment...",
success: isEditing ? "Appointment updated." : "Appointment created.",
error: isEditing ? "Failed to update appointment." : "Failed to create appointment.",
})
return promise
},
onSuccess: () => {
form.reset()
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)} className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>{error.message}</AlertTitle>
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="e.g. Oil Change Appointment" required />
<div className="grid grid-cols-2 gap-4">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfSelectField
name="status"
label="Status"
options={APPOINTMENT_STATUS_OPTIONS}
placeholder="Select status"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<RhfTextField name="from_time" label="From Time" placeholder="HH:MM" type="time" />
<RhfTextField name="to_time" label="To Time" placeholder="HH:MM" type="time" />
</div>
</FieldGroup>
<FieldGroup>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</FieldGroup>
<FieldGroup>
<RhfEmployeeSelectField name="service_writer" label="Service Writer" />
<RhfEmployeeSelectField name="technician" label="Technician" />
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department..."
queryKey={["departments-lookup"]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</FieldGroup>
<FieldGroup>
<RhfAsyncSelectField
name="job_card"
label="Job Card (optional)"
placeholder="Select job card..."
queryKey={["job-cards-lookup"]}
listFn={() => api.jobCards.list()}
mapOption={mapJobCardOption}
{...STORE_OBJECT}
/>
</FieldGroup>
<FieldGroup>
<RhfTextareaField name="notes" label="Notes" placeholder="Additional notes..." />
</FieldGroup>
<Button type="submit" disabled={isPending} className="w-full">
{isEditing ? <Save className="size-4" /> : <Plus className="size-4" />}
{isPending
? (isEditing ? "Saving..." : "Creating...")
: (isEditing ? "Save Changes" : "Create Appointment")}
</Button>
</Rhform>
)
}
@@ -0,0 +1,166 @@
import {
CalendarCheck2,
Clock,
User,
Car,
Users,
Wrench,
Building2,
FileText,
ClipboardList,
Info,
ExternalLink,
} from "lucide-react"
import Link from "next/link"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
type AppointmentData = {
id?: number
job_card_id?: number
title?: string
date?: string
from_time?: string
to_time?: string
customer_id?: number
vehicle_id?: number
service_writer_id?: number
technician_id?: number
department_id?: number
notes?: string
status?: string
created_at?: string
updated_at?: string
}
type AppointmentGeneralInfoProps = {
appointment: AppointmentData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | number | 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 != null && value !== "" ? String(value) : <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
const STATUS_COLORS: Record<string, string> = {
requested: "bg-yellow-100 text-yellow-800",
confirmed: "bg-blue-100 text-blue-800",
in_progress: "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
cancelled: "bg-red-100 text-red-800",
}
export function AppointmentGeneralInfo({ appointment }: AppointmentGeneralInfoProps) {
const statusClass = appointment.status
? (STATUS_COLORS[appointment.status] ?? "bg-gray-100 text-gray-800")
: "bg-gray-100 text-gray-800"
return (
<div className="grid gap-6 md:grid-cols-2 p-6">
{/* Appointment Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarCheck2 className="size-4" />
Appointment Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex items-center gap-2">
{appointment.status && (
<Badge className={statusClass}>
{appointment.status.replace("_", " ")}
</Badge>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Info} label="Title" value={appointment.title} />
<InfoItem icon={CalendarCheck2} label="Date" value={appointment.date} />
<InfoItem icon={Clock} label="From Time" value={appointment.from_time} />
<InfoItem icon={Clock} label="To Time" value={appointment.to_time} />
</div>
</CardContent>
</Card>
{/* Customer & Vehicle */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Customer &amp; Vehicle
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={User} label="Customer ID" value={appointment.customer_id} />
<InfoItem icon={Car} label="Vehicle ID" value={appointment.vehicle_id} />
</div>
</CardContent>
</Card>
{/* Assignment */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="size-4" />
Assignment
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Users} label="Service Writer ID" value={appointment.service_writer_id} />
<InfoItem icon={Wrench} label="Technician ID" value={appointment.technician_id} />
<InfoItem icon={Building2} label="Department ID" value={appointment.department_id} />
</div>
{appointment.job_card_id && (
<div className="pt-2">
<span className="text-xs text-muted-foreground mb-1 block">Job Card</span>
<Button asChild variant="outline" size="sm" className="gap-1.5">
<Link href={`/sales/job-cards/${appointment.job_card_id}`}>
<ClipboardList className="size-3.5" />
Job Card #{appointment.job_card_id}
<ExternalLink className="size-3.5" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Notes */}
{appointment.notes && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-line">{appointment.notes}</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,33 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const APPOINTMENT_STATUS_OPTIONS = [
{ value: "requested", label: "Requested" },
{ value: "confirmed", label: "Confirmed" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
]
const appointmentFormSchema = z.object({
title: z.string().min(1, "Title is required"),
date: z.string().min(1, "Date is required"),
from_time: z.string().min(1, "Start time is required"),
to_time: z.string().min(1, "End time is required"),
customer: relationFieldSchema,
vehicle: relationFieldSchema,
service_writer: relationFieldSchema,
technician: relationFieldSchema,
department: relationFieldSchema,
job_card: relationFieldSchema,
notes: z.string().optional(),
status: z.string().optional(),
})
type AppointmentFormValues = z.infer<typeof appointmentFormSchema>
export { appointmentFormSchema, relationFieldSchema, APPOINTMENT_STATUS_OPTIONS }
export type { AppointmentFormValues }
+197
View File
@@ -0,0 +1,197 @@
"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,
RhfAsyncSelectField,
RhfSelectField,
RhfTextField,
RhfTextareaField,
} from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import { useAuthApi } from "@/shared/useApi"
import { BillStatus, BILL_ROUTES, DEPARTMENT_ROUTES, JOB_CARD_ROUTES, PAYMENT_TERM_ROUTES, VENDOR_ROUTES } from "@garage/api"
import { toast } from "sonner"
import { billFormSchema, type BillFormValues } from "./bill.schema"
export type BillFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: BillFormValues = {
vendor: null,
job_card: null,
payment_term: null,
department: null,
title: "",
bill_date: "",
bill_due_date: "",
status: "draft",
notes: "",
}
const STATUS_OPTIONS = BillStatus.map((value) => ({
value,
label: value.replaceAll("_", " "),
}))
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): BillFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
job_card: toRelation(d.job_card_id, d.job_card_number ?? d.job_card_name),
payment_term: toRelation(d.payment_terms_id, d.payment_terms_name),
department: toRelation(d.department_id, d.department_name),
title: d.title || "",
bill_date: d.bill_date || "",
bill_due_date: d.bill_due_date || "",
status: d.status || "draft",
notes: d.notes || "",
}
}
function mapFormToPayload(values: BillFormValues) {
return {
title: values.title,
vendor_id: toId(values.vendor),
job_card_id: toId(values.job_card),
payment_terms_id: toId(values.payment_term),
department_id: toId(values.department),
bill_date: values.bill_date || undefined,
bill_due_date: values.bill_due_date || undefined,
status: values.status || undefined,
notes: values.notes || undefined,
}
}
export function BillForm({ resourceId, initialData, onSuccess }: BillFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<BillFormValues, any>({
schema: billFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: BillFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.bills.update(resourceId, payload)
: api.bills.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating bill..." : "Creating bill...",
success: isEditing ? "Bill updated successfully" : "Bill created successfully",
error: isEditing ? "Failed to update bill" : "Failed to create bill",
})
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 bill" : "Failed to create bill"}</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter bill title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="bill_date" label="Bill Date" type="date" />
<RhfTextField name="bill_due_date" label="Due Date" type="date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.job_card_number || item.name || `#${item.id}` })}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="payment_term"
label="Payment Term"
placeholder="Select payment term"
queryKey={[PAYMENT_TERM_ROUTES.INDEX]}
listFn={() => api.paymentTerms.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Bill
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Bill
</>
)}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,22 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const billFormSchema = z.object({
vendor: relationFieldSchema,
job_card: relationFieldSchema,
payment_term: relationFieldSchema,
department: relationFieldSchema,
title: z.string().min(1, "Title is required"),
bill_date: z.string().optional(),
bill_due_date: z.string().optional(),
status: z.string().optional(),
notes: z.string().optional(),
})
type BillFormValues = z.infer<typeof billFormSchema>
export { billFormSchema, relationFieldSchema }
export type { BillFormValues }
@@ -0,0 +1,65 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { confirm } from "@/shared/components/confirm-dialog"
import { toast } from "sonner"
type CustomerActionsProps = {
customerId: string
}
export function CustomerActions({ customerId }: CustomerActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/sales/customers/${customerId}/edit`)
}
const handleDelete = async () => {
const confirmed = await confirm({
title: "Delete Customer",
description: "Are you sure you want to delete this customer? This action cannot be undone.",
confirmLabel: "Delete",
variant: "destructive",
})
if (!confirmed) return
try {
await api.customers.destroy(customerId)
toast.success("Customer deleted successfully.")
router.push("/sales/customers")
} catch {
toast.error("Failed to delete customer.")
}
}
return (
<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>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type CustomerContextValue = {
id: string
label: string
}
const CustomerContext = createContext<CustomerContextValue | null>(null)
export function CustomerProvider({
customer,
children,
}: {
customer: CustomerContextValue
children: React.ReactNode
}) {
return (
<CustomerContext.Provider value={customer}>
{children}
</CustomerContext.Provider>
)
}
export function useCustomer() {
return useContext(CustomerContext)
}
@@ -0,0 +1,167 @@
import {
User,
Mail,
Phone,
MapPin,
Building2,
Globe,
DollarSign,
CreditCard,
Tag,
Users,
FileText,
} 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 CustomerData = {
id?: number
customer_type_id?: number
salutation?: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
alternate_phone?: string
opening_balance?: number
credit_limit?: number
website?: string
referral_source_id?: number
payment_terms_id?: number
address_line_1?: string
address_line_2?: string
country_id?: number
state_id?: number
city?: string
zip_code?: string
created_at?: string
updated_at?: string
customer_type?: { id?: number; name?: string }
referral_source?: { id?: number; title?: string }
payment_terms?: { id?: number; title?: string }
country?: { id?: number; name?: string }
state?: { id?: number; name?: string }
}
type CustomerGeneralInfoProps = {
customer: CustomerData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | number | 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 != null && value !== "" ? (
String(value)
) : (
<span className="text-muted-foreground">—</span>
)}
</span>
</div>
</div>
)
}
export function CustomerGeneralInfo({ customer }: CustomerGeneralInfoProps) {
const fullName = [customer.salutation, customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Personal Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
{customer.customer_type?.name && (
<Badge variant="outline">{customer.customer_type.name}</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={User} label="Full Name" value={fullName} />
<InfoItem icon={Building2} label="Company" value={customer.company_name} />
<InfoItem icon={Tag} label="Customer Type" value={customer.customer_type?.name} />
<InfoItem icon={Users} label="Referral Source" value={customer.referral_source?.title} />
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="size-4" />
Contact Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Mail} label="Email" value={customer.email} />
<InfoItem icon={Phone} label="Phone" value={customer.phone} />
<InfoItem icon={Phone} label="Alternate Phone" value={customer.alternate_phone} />
<InfoItem icon={Globe} label="Website" value={customer.website} />
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="size-4" />
Address
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={MapPin} label="Address Line 1" value={customer.address_line_1} />
<InfoItem icon={MapPin} label="Address Line 2" value={customer.address_line_2} />
<InfoItem icon={MapPin} label="City" value={customer.city} />
<InfoItem icon={MapPin} label="ZIP Code" value={customer.zip_code} />
<InfoItem icon={MapPin} label="State" value={customer.state?.name} />
<InfoItem icon={MapPin} label="Country" value={customer.country?.name} />
</CardContent>
</Card>
{/* Financial Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="size-4" />
Financial Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={DollarSign} label="Opening Balance" value={customer.opening_balance} />
<InfoItem icon={CreditCard} label="Credit Limit" value={customer.credit_limit} />
<InfoItem icon={FileText} label="Payment Terms" value={customer.payment_terms?.title} />
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,196 @@
"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Building2, Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { CUSTOMER_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
// ── Customer option type (enriched for display) ──
type CustomerOption = {
value: string
label: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
}
function buildCustomerOption(item: any): CustomerOption {
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
const label = name || item.company_name || `Customer #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
company_name: item.company_name,
email: item.email,
phone: item.phone,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: CustomerOption): string {
if (opt.first_name || opt.last_name) {
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
}
if (opt.company_name) return opt.company_name[0].toUpperCase()
return "?"
}
// ── Props ──
export type RhfCustomerSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfCustomerSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Customer",
description,
required,
disabled,
placeholder = "Search by name, company, or phone...",
}: RhfCustomerSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<CustomerOption[]>({
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select"],
queryFn: async () => {
const res = await api.customers.list()
return extractItems(res).map(buildCustomerOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((c) =>
[c.first_name, c.last_name, c.company_name, c.email, c.phone]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: CustomerOption | CustomerOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
field.onChange(
single ? { value: single.value, label: single.label } : null,
)
}}
disabled={field.disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: CustomerOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList className='overflow-auto'>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
{/* Avatar circle with initials */}
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-2 min-w-0">
{opt.company_name && opt.label !== opt.company_name && (
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
{opt.company_name}
</span>
)}
{opt.phone && (
<span className="text-xs text-muted-foreground truncate">
{opt.phone}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No customers found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
}
@@ -0,0 +1,70 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { EmployeeForm } from "./employee-form"
type EmployeeActionsProps = {
employeeId: string
}
export function EmployeeActions({ employeeId }: EmployeeActionsProps) {
const api = useAuthApi()
const router = useRouter()
const editDialog = useFormDialog("employee_edit")
const handleDelete = async () => {
await api.employees.destroy(employeeId)
router.push("/productivity/employees")
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(employeeId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Employee</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EmployeeForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,178 @@
"use client"
import { useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
// ── Types ──
export type EmployeeOption = {
value: string
label: string
first_name?: string
last_name?: string
email?: string
type?: string
status?: string
}
// ── Helpers ──
function buildEmployeeOption(item: any): EmployeeOption {
const label =
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
`Employee #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
type: item.type,
status: item.status,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: EmployeeOption): string {
return (
[opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "E"
)
}
// ── Props ──
type EmployeeComboboxProps = {
value?: EmployeeOption | null
onValueChange: (employee: EmployeeOption | null) => void
disabled?: boolean
placeholder?: string
showClear?: boolean
onBlur?: () => void
"aria-invalid"?: boolean
}
// ── Component ──
export function EmployeeCombobox({
value,
onValueChange,
disabled,
placeholder = "Search by name or email...",
showClear,
onBlur,
"aria-invalid": ariaInvalid,
}: EmployeeComboboxProps) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { data: options = [], isLoading } = useQuery<EmployeeOption[]>({
queryKey: [EMPLOYEE_ROUTES.INDEX, "employee-select"],
queryFn: async () => {
const res = await api.employees.list()
return extractItems(res).map(buildEmployeeOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((e) =>
[e.first_name, e.last_name, e.email, e.type]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<div ref={anchorRef}>
<Combobox
value={value}
onValueChange={(val: EmployeeOption | EmployeeOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
onValueChange(single)
}}
disabled={disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: EmployeeOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={showClear ?? !!value}
onBlur={onBlur}
aria-invalid={ariaInvalid || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-1.5">
{opt.type && (
<Badge
variant="secondary"
className="h-4 px-1.5 text-xs font-normal capitalize"
>
{opt.type}
</Badge>
)}
{opt.status && (
<span
className={`text-xs capitalize ${opt.status === "active" ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
>
{opt.status}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No employees found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EmployeeContextValue = {
id: string
label: string
}
const EmployeeContext = createContext<EmployeeContextValue | null>(null)
export function EmployeeProvider({
employee,
children,
}: {
employee: EmployeeContextValue
children: React.ReactNode
}) {
return (
<EmployeeContext.Provider value={employee}>
{children}
</EmployeeContext.Provider>
)
}
export function useEmployee() {
return useContext(EmployeeContext)
}
@@ -0,0 +1,159 @@
import {
User,
Mail,
Phone,
Briefcase,
Building2,
Clock,
MapPin,
Calendar,
BadgeCheck,
CircleDot,
} 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 EmployeeData = {
id?: number
type?: string
first_name?: string
last_name?: string
email?: string
phone?: string
position?: string
designation?: string
salary?: string
wage_type?: string
status?: string
track_attendance?: boolean
notify_owner_when_punch_in_out?: boolean
geo_fence_radius?: string | number | null
department?: { id?: number; name?: string } | null
shop_calender?: { id?: number; title?: string } | null
shop_timing?: { id?: number; title?: string } | null
created_at?: string
updated_at?: string
}
type EmployeeGeneralInfoProps = {
employee: EmployeeData
}
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>
)
}
export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ")
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Personal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
{employee.type && (
<Badge variant="outline" className="capitalize">
{employee.type}
</Badge>
)}
{employee.status && (
<Badge
variant={employee.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{employee.status}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Mail} label="Email" value={employee.email} />
<InfoItem icon={Phone} label="Phone" value={employee.phone} />
<InfoItem icon={Briefcase} label="Position" value={employee.position} />
<InfoItem icon={BadgeCheck} label="Designation" value={employee.designation} />
</div>
</CardContent>
</Card>
{/* Work Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Work Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Building2}
label="Department"
value={employee.department?.name}
/>
<InfoItem
icon={Calendar}
label="Shop Calendar"
value={employee.shop_calender?.title}
/>
<InfoItem
icon={Clock}
label="Shop Timing"
value={employee.shop_timing?.title}
/>
<InfoItem
icon={MapPin}
label="Geo Fence Radius"
value={employee.geo_fence_radius != null ? String(employee.geo_fence_radius) : null}
/>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant={employee.track_attendance ? "default" : "secondary"}>
<CircleDot className="mr-1 size-3" />
{employee.track_attendance ? "Attendance Tracked" : "No Attendance Tracking"}
</Badge>
{employee.notify_owner_when_punch_in_out && (
<Badge variant="outline">
Punch In/Out Notifications
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,292 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { toast } from "sonner"
import { Save, ShieldCheck } from "lucide-react"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
// ── Permission resource groups ──
const PERMISSION_GROUPS: { label: string; key: string }[] = [
{ label: "Appointments", key: "appointments" },
{ label: "Bills", key: "bills" },
{ label: "Check Point Labels", key: "check_point_labels" },
{ label: "Credit Notes", key: "credit_notes" },
{ label: "Customers", key: "customers" },
{ label: "Departments", key: "departments" },
{ label: "Document Types", key: "document_types" },
{ label: "Employees", key: "employees" },
{ label: "Estimates", key: "estimates" },
{ label: "Expense Items", key: "expense_items" },
{ label: "Expenses", key: "expenses" },
{ label: "Holidays", key: "holidays" },
{ label: "Inspection Categories", key: "inspection_categories" },
{ label: "Inspection Check Points", key: "inspection_check_points" },
{ label: "Inspections", key: "inspections" },
{ label: "Insurance Types", key: "insurance_types" },
{ label: "Inventory Adjustments", key: "inventory_adjustments" },
{ label: "Inventory Categories", key: "inventory_categories" },
{ label: "Invoice Documents", key: "invoice_documents" },
{ label: "Invoice Labels", key: "invoice_labels" },
{ label: "Invoice Notes", key: "invoice_notes" },
{ label: "Invoice Sequences", key: "invoice_sequences" },
{ label: "Invoices", key: "invoices" },
{ label: "Job Cards", key: "job_cards" },
{ label: "Labels", key: "labels" },
{ label: "Labor Rates", key: "labor_rates" },
{ label: "Make & Models", key: "make_and_models" },
{ label: "Parts", key: "parts" },
{ label: "Payment Mades", key: "payment_mades" },
{ label: "Payment Modes", key: "payment_modes" },
{ label: "Payment Received", key: "payment_recieveds" },
{ label: "Payment Terms", key: "payment_terms" },
{ label: "Purchase Orders", key: "purchase_orders" },
{ label: "Quick Notes", key: "quick_notes" },
{ label: "Quick Remarks", key: "quick_remarks" },
{ label: "Reasons", key: "reasons" },
{ label: "Referral Sources", key: "referral_sources" },
{ label: "Service Group Includes", key: "service_group_includes" },
{ label: "Service Group Parts", key: "service_group_parts" },
{ label: "Service Group Pricings", key: "service_group_pricings" },
{ label: "Service Group Services", key: "service_group_services" },
{ label: "Service Groups", key: "service_groups" },
{ label: "Services", key: "services" },
{ label: "Settings", key: "settings" },
{ label: "Shop Calendars", key: "shop_calenders" },
{ label: "Shop Timings", key: "shop_timings" },
{ label: "Shop Types", key: "shop_types" },
{ label: "Task Sections", key: "task_sections" },
{ label: "Task Types", key: "task_types" },
{ label: "Tasks", key: "tasks" },
{ label: "Taxes", key: "taxes" },
{ label: "Time Sheets", key: "time_sheets" },
{ label: "Unit Types", key: "unit_types" },
{ label: "Vehicle Body Types", key: "vehicle_body_types" },
{ label: "Vehicle Colors", key: "vehicle_colors" },
{ label: "Vehicle Documents", key: "vehicle_documents" },
{ label: "Vehicle Fuel Types", key: "vehicle_fuel_types" },
{ label: "Vehicle Mile & Kms", key: "vehicle_mile_and_kms" },
{ label: "Vehicle Transmissions", key: "vehicle_transmissions" },
{ label: "Vehicles", key: "vehicles" },
{ label: "Vendor Credits", key: "vendor_credits" },
{ label: "Vendors", key: "vendors" },
]
const ACTIONS = ["view", "create", "update", "delete"] as const
type PermissionAction = typeof ACTIONS[number]
type PermissionsState = Record<string, boolean>
function buildPermissionsPayload(state: PermissionsState) {
return state
}
function extractPermissions(data: Record<string, unknown> | null | undefined): PermissionsState {
if (!data) return {}
const result: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
const key = `can_${action}_${group.key}`
result[key] = Boolean(data[key])
}
}
return result
}
type EmployeePermissionsFormProps = {
employeeId: string
}
export function EmployeePermissionsForm({ employeeId }: EmployeePermissionsFormProps) {
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = [EMPLOYEE_ROUTES.BY_ID, employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.getById(employeeId),
})
const employeeData = (data as any)?.data as Record<string, unknown> | undefined
const [permissions, setPermissions] = useState<PermissionsState | null>(null)
const currentPermissions = permissions ?? extractPermissions(employeeData)
const { mutate, isPending } = useMutation({
mutationFn: (payload: PermissionsState) =>
api.employees.updatePermissions(employeeId, payload as never),
onSuccess: () => {
toast.success("Permissions updated successfully")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to update permissions")
},
})
const handleToggle = (key: string, value: boolean) => {
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
[key]: value,
}))
}
const handleRowToggle = (groupKey: string, checked: boolean) => {
const updates: PermissionsState = {}
for (const action of ACTIONS) {
updates[`can_${action}_${groupKey}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const handleColumnToggle = (action: PermissionAction, checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
updates[`can_${action}_${group.key}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const isRowChecked = (groupKey: string) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${groupKey}`])
const isRowIndeterminate = (groupKey: string) => {
const values = ACTIONS.map((action) => currentPermissions[`can_${action}_${groupKey}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isColumnChecked = (action: PermissionAction) =>
PERMISSION_GROUPS.every((g) => currentPermissions[`can_${action}_${g.key}`])
const isColumnIndeterminate = (action: PermissionAction) => {
const values = PERMISSION_GROUPS.map((g) => currentPermissions[`can_${action}_${g.key}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isAllChecked = PERMISSION_GROUPS.every((g) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${g.key}`])
)
const isAllIndeterminate = (() => {
const values = PERMISSION_GROUPS.flatMap((g) =>
ACTIONS.map((action) => currentPermissions[`can_${action}_${g.key}`])
)
return values.some(Boolean) && !values.every(Boolean)
})()
const handleToggleAll = (checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
updates[`can_${action}_${group.key}`] = checked
}
}
setPermissions(updates)
}
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="text-muted-foreground text-sm">Loading permissions...</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
Permissions
</CardTitle>
<Button
size="sm"
onClick={() => mutate(buildPermissionsPayload(currentPermissions))}
disabled={isPending}
>
<Save className="size-4" />
Save Permissions
</Button>
</CardHeader>
<CardContent className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 text-left font-medium">
<div className="flex items-center gap-2">
<Checkbox
className="border-primary"
checked={isAllChecked || (isAllIndeterminate ? "indeterminate" : false)}
onCheckedChange={(v) => handleToggleAll(Boolean(v))}
/>
Resource
</div>
</th>
{ACTIONS.map((action) => (
<th key={action} className="py-2 px-4 text-center font-medium capitalize">
<div className="flex flex-col items-center gap-1">
<Checkbox
className="border border-primary"
checked={isColumnChecked(action) || (isColumnIndeterminate(action) ? "indeterminate" : false)}
onCheckedChange={(v) => handleColumnToggle(action, Boolean(v))}
/>
<Badge variant="outline" className="capitalize text-xs">
{action}
</Badge>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map((group) => (
<tr key={group.key} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
<Checkbox
className="border border-primary"
checked={isRowChecked(group.key) || (isRowIndeterminate(group.key) ? "indeterminate" : false)}
onCheckedChange={(v) => handleRowToggle(group.key, Boolean(v))}
/>
<span className="text-sm">{group.label}</span>
</div>
</td>
{ACTIONS.map((action) => {
const key = `can_${action}_${group.key}`
return (
<td key={action} className="py-2 px-4 text-center ">
<Checkbox
className="mx-auto border border-primary"
checked={Boolean(currentPermissions[key])}
onCheckedChange={(v) => handleToggle(key, Boolean(v))}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</DashboardPage>
)
}
@@ -0,0 +1,60 @@
"use client"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { FieldShell } from "@/shared/components/form/field-shell"
import { EmployeeCombobox } from "./employee-combobox"
// ── Props ──
export type RhfEmployeeSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfEmployeeSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Employee",
description,
required,
disabled,
placeholder = "Search by name or email...",
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<EmployeeCombobox
value={field.value}
onValueChange={(emp) => {
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
}}
disabled={field.disabled}
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
</FieldShell>
)
}
@@ -0,0 +1,75 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useState } from "react"
import { EstimateForm } from "./estimate-form"
type EstimateActionsProps = {
estimateId: string
}
export function EstimateActions({ estimateId }: EstimateActionsProps) {
const api = useAuthApi()
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const handleDelete = async () => {
await api.estimates.destroy(estimateId)
router.push("/sales/estimates")
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Estimate</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EstimateForm
resourceId={estimateId}
onSuccess={() => {
setEditOpen(false)
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EstimateContextValue = {
id: string
label: string
}
const EstimateContext = createContext<EstimateContextValue | null>(null)
export function EstimateProvider({
estimate,
children,
}: {
estimate: EstimateContextValue
children: React.ReactNode
}) {
return (
<EstimateContext.Provider value={estimate}>
{children}
</EstimateContext.Provider>
)
}
export function useEstimate() {
return useContext(EstimateContext)
}
@@ -23,7 +23,9 @@ import {
estimateFormSchema,
type EstimateFormValues,
} from "./estimate.schema"
import { ESTIMATE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
import { ESTIMATE_ROUTES, DEPARTMENT_ROUTES, LABEL_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Props ──
@@ -91,16 +93,6 @@ const mapLookupOption = (item: any) => ({
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 ──
@@ -157,24 +149,8 @@ export function EstimateForm({ resourceId, initialData, onSuccess }: EstimateFor
</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}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -0,0 +1,197 @@
import {
FileText,
Hash,
Calendar,
User,
Car,
Building2,
Shield,
Tag,
MessageSquare,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
type EstimateData = {
id?: number
title?: string
estimate_number?: string
date?: string
customer_id?: number
vehicle_id?: number
department_id?: number
has_insurance?: boolean
enable_digital_authorisation?: boolean
insurance_type_id?: string | number | null
insurer_id?: string | number | null
service_writer_id?: number
footer?: string | null
created_at?: string
updated_at?: string
labels?: {
id?: number
title?: string
color_code?: string
}[]
customer_remarks?: {
id?: number
remark?: string
created_at?: string
}[]
// Joined fields that may come from the API
customer_name?: string
vehicle_name?: string
department_name?: string
}
type EstimateGeneralInfoProps = {
estimate: EstimateData
}
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>
)
}
export function EstimateGeneralInfo({ estimate }: EstimateGeneralInfoProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Estimate Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={FileText} label="Title" value={estimate.title} />
<InfoItem icon={Hash} label="Estimate #" value={estimate.estimate_number} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Calendar} label="Date" value={estimate.date} />
<InfoItem
icon={Shield}
label="Insurance"
value={estimate.has_insurance ? "Yes" : "No"}
/>
</div>
<InfoItem
icon={Calendar}
label="Created"
value={
estimate.created_at
? new Date(estimate.created_at).toLocaleDateString()
: null
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" /> Related Records
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<InfoItem
icon={User}
label="Customer"
value={estimate.customer_name || (estimate.customer_id ? `#${estimate.customer_id}` : null)}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={estimate.vehicle_name || (estimate.vehicle_id ? `#${estimate.vehicle_id}` : null)}
/>
<InfoItem
icon={Building2}
label="Department"
value={estimate.department_name || (estimate.department_id ? `#${estimate.department_id}` : null)}
/>
</CardContent>
</Card>
{estimate.labels && estimate.labels.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="size-4" /> Labels
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{estimate.labels.map((label) => (
<Badge
key={label.id}
style={label.color_code ? { backgroundColor: label.color_code } : undefined}
>
{label.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{estimate.customer_remarks && estimate.customer_remarks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="size-4" /> Customer Remarks
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{estimate.customer_remarks.map((remark) => (
<div key={remark.id} className="rounded-md border p-3 text-sm">
<p>{remark.remark}</p>
{remark.created_at && (
<p className="mt-1 text-xs text-muted-foreground">
{new Date(remark.created_at).toLocaleDateString()}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{estimate.footer && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" /> Footer
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{estimate.footer}</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,88 @@
"use client"
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 { Ellipsis, Pencil, Trash2, Play, CheckCircle2 } from "lucide-react"
import { toast } from "sonner"
type InspectionActionsProps = {
inspectionId: string
status?: string
onStatusChange?: () => void
}
const STATUS_TRANSITIONS: Record<string, { next: string; label: string; icon: typeof Play }> = {
open: { next: "in_progress", label: "Start Inspection", icon: Play },
in_progress: { next: "completed", label: "Mark Completed", icon: CheckCircle2 },
}
export function InspectionActions({ inspectionId, status, onStatusChange }: InspectionActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/sales/inspections/${inspectionId}/edit`)
}
const handleDelete = async () => {
const promise = api.inspections.destroy(inspectionId)
toast.promise(promise, {
loading: "Deleting inspection...",
success: "Inspection deleted successfully",
error: "Failed to delete inspection",
})
await promise
router.push("/sales/inspections")
}
const handleStatusChange = async (newStatus: string) => {
const promise = api.inspections.changeStatus({
status: newStatus,
} as never)
toast.promise(promise, {
loading: "Updating status...",
success: "Status updated successfully",
error: "Failed to update status",
})
await promise
onStatusChange?.()
router.refresh()
}
const transition = status ? STATUS_TRANSITIONS[status] : undefined
return (
<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>
{transition && (
<DropdownMenuItem onClick={() => handleStatusChange(transition.next)}>
<transition.icon className="size-4" />
{transition.label}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type InspectionContextValue = {
id: string
label: string
}
const InspectionContext = createContext<InspectionContextValue | null>(null)
export function InspectionProvider({
inspection,
children,
}: {
inspection: InspectionContextValue
children: React.ReactNode
}) {
return (
<InspectionContext.Provider value={inspection}>
{children}
</InspectionContext.Provider>
)
}
export function useInspection() {
return useContext(InspectionContext)
}
@@ -24,11 +24,11 @@ import {
} from "./inspection.schema"
import {
INSPECTION_ROUTES,
CUSTOMER_ROUTES,
VEHICLE_ROUTES,
DEPARTMENT_ROUTES,
EMPLOYEE_ROUTES,
} from "@garage/api"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
// ── Props ──
@@ -91,21 +91,6 @@ const mapLookupOption = (item: any) => ({
label: item.name ?? item.title ?? String(item.id),
})
const mapCustomerOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const mapVehicleOption = (item: any) => ({
value: String(item.id),
label: `${item.make ?? ""} ${item.model ?? ""} ${item.year ?? ""}`.trim() || String(item.id),
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: `${item.first_name ?? ""} ${item.last_name ?? ""}`.trim() || String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
@@ -157,24 +142,8 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
<RhfTextField name="title" label="Title" placeholder="e.g. Pre-purchase" 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}
/>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -202,21 +171,13 @@ export function InspectionForm({ resourceId, initialData, onSuccess }: Inspectio
/>
</div>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<RhfEmployeeSelectField name="employee" />
<RhfTextField name="order_number" label="Order Number" placeholder="e.g. ORD-001" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="date" label="Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" />
<RhfTextField name="time" label="Time" placeholder="HH:MM:SS" type="time" step={1} />
</div>
<Button type="submit" variant="default" disabled={isPending}>
@@ -0,0 +1,215 @@
import {
ClipboardCheck,
Calendar,
Clock,
Hash,
FileText,
User,
Car,
Building2,
FolderOpen,
} 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 InspectionData = {
id?: number
title?: string
customer_id?: number
vehicle_id?: number
department_id?: number
inspection_category_id?: number
employee_id?: number
order_number?: string
date?: string
time?: string
note?: string
status?: string
created_at?: string
updated_at?: string
customer?: { id?: number; first_name?: string; last_name?: string }
vehicle?: { id?: number; make?: string; model?: string; license_plate?: string }
department?: { id?: number; name?: string }
inspection_category?: { id?: number; name?: string }
employee?: { id?: number; first_name?: string; last_name?: string }
}
type InspectionGeneralInfoProps = {
inspection: InspectionData
}
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 STATUS_COLORS: Record<string, string> = {
open: "bg-blue-100 text-blue-800",
in_progress: "bg-yellow-100 text-yellow-800",
completed: "bg-green-100 text-green-800",
}
function formatStatus(status?: string) {
if (!status) return "—"
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function InspectionGeneralInfo({ inspection }: InspectionGeneralInfoProps) {
const customerName = inspection.customer
? `${inspection.customer.first_name ?? ""} ${inspection.customer.last_name ?? ""}`.trim()
: undefined
const vehicleName = inspection.vehicle
? `${inspection.vehicle.make ?? ""} ${inspection.vehicle.model ?? ""}`.trim()
: undefined
const employeeName = inspection.employee
? `${inspection.employee.first_name ?? ""} ${inspection.employee.last_name ?? ""}`.trim()
: undefined
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Inspection Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardCheck className="size-4" />
Inspection Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
{inspection.title && (
<Badge variant="secondary">{inspection.title}</Badge>
)}
{inspection.status && (
<Badge className={STATUS_COLORS[inspection.status] || ""}>
{formatStatus(inspection.status)}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Hash}
label="Order Number"
value={inspection.order_number}
/>
<InfoItem
icon={FolderOpen}
label="Category"
value={inspection.inspection_category?.name}
/>
<InfoItem
icon={Calendar}
label="Date"
value={inspection.date ? new Date(inspection.date).toLocaleDateString() : null}
/>
<InfoItem
icon={Clock}
label="Time"
value={inspection.time}
/>
</div>
</CardContent>
</Card>
{/* Assignments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Assignments
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={User}
label="Customer"
value={customerName}
/>
<InfoItem
icon={Car}
label="Vehicle"
value={vehicleName}
/>
<InfoItem
icon={User}
label="Employee"
value={employeeName}
/>
<InfoItem
icon={Building2}
label="Department"
value={inspection.department?.name}
/>
</CardContent>
</Card>
{/* Notes & Timestamps */}
{(inspection.note || inspection.created_at) && (
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="size-4" />
Notes & Timestamps
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Calendar}
label="Created At"
value={inspection.created_at ? new Date(inspection.created_at).toLocaleString() : null}
/>
<InfoItem
icon={Calendar}
label="Updated At"
value={inspection.updated_at ? new Date(inspection.updated_at).toLocaleString() : null}
/>
</div>
{inspection.note && (
<>
<Separator />
<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">
<FileText className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Note</span>
<p className="text-sm whitespace-pre-wrap">{inspection.note}</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,227 @@
"use client"
import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react"
import { useFieldArray } from "react-hook-form"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfTextareaField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { 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 {
inventoryAdjustmentFormSchema,
type InventoryAdjustmentFormValues,
} from "./inventory-adjustment.schema"
import { INVENTORY_ADJUSTMENT_ROUTES, REASON_ROUTES, PARTS_ROUTES } from "@garage/api"
// ── Props ──
export type InventoryAdjustmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: InventoryAdjustmentFormValues = {
reference_number: "",
date: "",
chart_of_account: "",
reason: null,
notes: "",
parts: [{ part: null, quantity: 1, rate: 0 }],
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): InventoryAdjustmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
reference_number: d.reference_number || "",
date: d.date || "",
chart_of_account: d.chart_of_account || "",
reason: toRelation(d.reason_id, d.reason_name),
notes: d.notes || "",
parts: Array.isArray(d.parts) && d.parts.length > 0
? d.parts.map((p: any) => ({
part: toRelation(p.part_id, p.part_name),
quantity: p.quantity ?? 1,
rate: p.rate ?? 0,
}))
: [{ part: null, quantity: 1, rate: 0 }],
}
}
function mapFormToPayload(values: InventoryAdjustmentFormValues) {
return {
reference_number: values.reference_number || undefined,
date: values.date || undefined,
chart_of_account: values.chart_of_account || undefined,
reason_id: toId(values.reason) ? Number(toId(values.reason)) : undefined,
notes: values.notes || undefined,
parts: values.parts.map((p) => ({
part_id: toId(p.part) ? Number(toId(p.part)) : undefined,
quantity: p.quantity,
rate: p.rate,
})),
}
}
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name ?? item.title })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function InventoryAdjustmentForm({ resourceId, initialData, onSuccess }: InventoryAdjustmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InventoryAdjustmentFormValues, any>({
schema: inventoryAdjustmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "parts",
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InventoryAdjustmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.inventoryAdjustments.update(resourceId, payload as never)
: api.inventoryAdjustments.create(payload as never)
return promise
},
onSuccess: () => {
toast.success(isEditing ? "Adjustment updated." : "Adjustment created.")
onSuccess?.()
},
})
return (
<Rhform form={form} onSubmit={mutate} className="flex flex-col gap-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>
{isEditing ? "Failed to update adjustment" : "Failed to create adjustment"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="reference_number"
label="Reference Number"
placeholder="ADJ-001"
/>
<RhfTextField
name="date"
label="Date"
type="date"
/>
<RhfTextField
name="chart_of_account"
label="Chart of Account"
placeholder="Account name"
/>
<RhfAsyncSelectField
name="reason"
label="Reason"
placeholder="Select reason..."
queryKey={[REASON_ROUTES.INDEX]}
listFn={() => api.reasons.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextareaField
name="notes"
label="Notes"
placeholder="Additional notes..."
rows={3}
/>
</FieldGroup>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Parts</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ part: null, quantity: 1, rate: 0 })}
>
<Plus className="size-4" />
Add Part
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">No parts added. Click "Add Part" to begin.</p>
)}
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr_auto_auto_auto] items-end gap-2 rounded-lg border p-3">
<RhfAsyncSelectField
name={`parts.${index}.part`}
label="Part"
placeholder="Select part..."
queryKey={[PARTS_ROUTES.INDEX]}
listFn={() => api.parts.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfTextField
name={`parts.${index}.quantity`}
label="Qty"
type="number"
placeholder="1"
/>
<RhfTextField
name={`parts.${index}.rate`}
label="Rate"
type="number"
placeholder="0"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="mb-0.5"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
))}
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save className="size-4" />
{isPending ? "Saving..." : isEditing ? "Update Adjustment" : "Create Adjustment"}
</Button>
</div>
</Rhform>
)
}
@@ -0,0 +1,23 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const partLineSchema = z.object({
part: relationFieldSchema,
quantity: z.coerce.number().min(1, "Quantity must be at least 1"),
rate: z.coerce.number().min(0, "Rate must be 0 or more"),
})
export const inventoryAdjustmentFormSchema = z.object({
reference_number: z.string().optional(),
date: z.string().optional(),
chart_of_account: z.string().optional(),
reason: relationFieldSchema,
notes: z.string().optional(),
parts: z.array(partLineSchema).min(1, "At least one part is required"),
})
export type InventoryAdjustmentFormValues = z.infer<typeof inventoryAdjustmentFormSchema>
export type PartLineValues = z.infer<typeof partLineSchema>
@@ -22,7 +22,9 @@ import {
invoiceFormSchema,
type InvoiceFormValues,
} from "./invoice.schema"
import { INVOICE_ROUTES, CUSTOMER_ROUTES, VEHICLE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { INVOICE_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
import { RhfCustomerSelectField } from "@/modules/customers/rhf-customer-select-field"
import { RhfVehicleSelectField } from "@/modules/vehicles/rhf-vehicle-select-field"
// ── Constants ──
@@ -95,16 +97,6 @@ const mapLookupOption = (item: any) => ({
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 ──
@@ -171,25 +163,8 @@ export function InvoiceForm({ resourceId, initialData, onSuccess }: InvoiceFormP
</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}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="vehicle"
label="Vehicle"
placeholder="Select vehicle"
queryKey={[VEHICLE_ROUTES.INDEX]}
listFn={() => api.vehicles.list()}
mapOption={mapVehicleOption}
{...STORE_OBJECT}
/>
<RhfCustomerSelectField name="customer" />
<RhfVehicleSelectField name="vehicle" />
</div>
<RhfAsyncSelectField
@@ -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"
@@ -0,0 +1,254 @@
"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,
RhfTextareaField,
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 {
paymentMadeFormSchema,
type PaymentMadeFormValues,
} from "./payment-made.schema"
import {
PAYMENT_MADE_ROUTES,
PAYMENT_ROUTES,
VENDOR_ROUTES,
EMPLOYEE_ROUTES,
PaymentFor,
} from "@garage/api"
// ── Constants ──
const PAYMENT_FOR_OPTIONS = PaymentFor.map((value) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
// ── Props ──
export type PaymentMadeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PaymentMadeFormValues = {
vendor: null,
employee: null,
payment_mode: null,
payment_for: "",
payment_made: "",
payment_number: "",
payment_reference: "",
payment_date: "",
paid_through: "",
notes: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): PaymentMadeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
employee: toRelation(d.employee_id, d.employee_name),
payment_mode: toRelation(d.payment_mode_id, d.payment_mode_name),
payment_for: d.payment_for || "",
payment_made: d.payment_made ? String(d.payment_made) : "",
payment_number: d.payment_number || "",
payment_reference: d.payment_reference || "",
payment_date: d.payment_date || "",
paid_through: d.paid_through || "",
notes: d.notes || "",
}
}
function mapFormToPayload(values: PaymentMadeFormValues) {
return {
vendor_id: toId(values.vendor),
employee_id: toId(values.employee) || undefined,
payment_mode_id: toId(values.payment_mode),
payment_for: values.payment_for,
payment_made: values.payment_made,
payment_number: values.payment_number || undefined,
payment_reference: values.payment_reference || undefined,
payment_date: values.payment_date,
paid_through: values.paid_through || undefined,
notes: values.notes || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? `#${item.id}`,
})
const mapVendorOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.company_name ?? `#${item.id}`,
})
const mapEmployeeOption = (item: any) => ({
value: String(item.id),
label: item.first_name
? `${item.first_name} ${item.last_name || ""}`.trim()
: item.name ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function PaymentMadeForm({ resourceId, initialData, onSuccess }: PaymentMadeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PaymentMadeFormValues, any>({
schema: paymentMadeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PaymentMadeFormValues) => {
const payload = mapFormToPayload(values)
const promise = (isEditing && resourceId
? api.paymentMades.update(resourceId, payload as any)
: api.paymentMades.create(payload as any)) as Promise<any>
toast.promise(promise, {
loading: isEditing ? "Updating payment..." : "Recording payment...",
success: isEditing ? "Payment updated successfully" : "Payment recorded successfully",
error: isEditing ? "Failed to update payment" : "Failed to record payment",
})
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 payment" : "Failed to record payment"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapVendorOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
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">
<RhfSelectField
name="payment_for"
label="Payment For"
placeholder="Select type"
options={PAYMENT_FOR_OPTIONS}
required
/>
<RhfTextField
name="payment_made"
label="Amount"
placeholder="0.00"
type="number"
required
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="payment_number"
label="Payment Number"
placeholder="PAY-001"
/>
<RhfTextField
name="payment_reference"
label="Payment Reference"
placeholder="Reference"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="payment_date"
label="Payment Date"
type="date"
required
/>
<RhfAsyncSelectField
name="payment_mode"
label="Payment Mode"
placeholder="Select payment mode"
queryKey={[PAYMENT_ROUTES.MODES]}
listFn={() => api.payments.listModes()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextField
name="paid_through"
label="Paid Through"
placeholder="Account name"
/>
<RhfTextareaField
name="notes"
label="Notes"
rows={3}
placeholder="Add any notes about this payment..."
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Recording...")
: (isEditing ? "Update Payment" : "Record Payment")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,26 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const paymentMadeFormSchema = z.object({
// ── Relations ──
vendor: relationFieldSchema,
employee: relationFieldSchema,
payment_mode: relationFieldSchema,
// ── Payment info ──
payment_for: z.string().min(1, "Payment for is required"),
payment_made: z.string().min(1, "Amount is required"),
payment_number: z.string().optional(),
payment_reference: z.string().optional(),
payment_date: z.string().min(1, "Payment date is required"),
paid_through: z.string().optional(),
notes: z.string().optional(),
})
type PaymentMadeFormValues = z.infer<typeof paymentMadeFormSchema>
export { paymentMadeFormSchema, relationFieldSchema }
export type { PaymentMadeFormValues }
@@ -0,0 +1,189 @@
"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,
RhfTextareaField,
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 {
purchaseOrderFormSchema,
type PurchaseOrderFormValues,
} from "./purchase-order.schema"
import { PURCHASE_ORDER_ROUTES, VENDOR_ROUTES, JOB_CARD_ROUTES, DEPARTMENT_ROUTES } from "@garage/api"
// ── Props ──
export type PurchaseOrderFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: PurchaseOrderFormValues = {
vendor: null,
job_card: null,
department: null,
title: "",
order_date: "",
delivery_date: "",
notes: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): PurchaseOrderFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
job_card: toRelation(d.job_card_id, d.job_card_name),
department: toRelation(d.department_id, d.department_name),
title: d.title || "",
order_date: d.order_date || "",
delivery_date: d.delivery_date || "",
notes: d.notes || "",
}
}
function mapFormToPayload(values: PurchaseOrderFormValues) {
return {
vendor_id: toId(values.vendor),
job_card_id: toId(values.job_card),
department_id: toId(values.department),
title: values.title,
order_date: values.order_date || undefined,
delivery_date: values.delivery_date || undefined,
notes: values.notes || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function PurchaseOrderForm({ resourceId, initialData, onSuccess }: PurchaseOrderFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<PurchaseOrderFormValues, any>({
schema: purchaseOrderFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PurchaseOrderFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.purchaseOrders.update(resourceId, payload)
: api.purchaseOrders.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating purchase order..." : "Creating purchase order...",
success: isEditing ? "Purchase order updated successfully" : "Purchase order created successfully",
error: isEditing ? "Failed to update purchase order" : "Failed to create purchase order",
})
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 purchase order" : "Failed to create purchase order"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter purchase order title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="order_date" label="Order Date" placeholder="YYYY-MM-DD" type="date" />
<RhfTextField name="delivery_date" label="Delivery Date" placeholder="YYYY-MM-DD" type="date" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="job_card"
label="Job Card"
placeholder="Select job card"
queryKey={[JOB_CARD_ROUTES.INDEX]}
listFn={() => api.jobCards.list()}
mapOption={(item: any) => ({
value: String(item.id),
label: item.job_card_number || item.name || `#${item.id}`,
})}
{...STORE_OBJECT}
/>
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" variant="default" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Purchase Order
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Purchase Order
</>
)}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,23 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const purchaseOrderFormSchema = z.object({
// ── Relations ──
vendor: relationFieldSchema,
job_card: relationFieldSchema,
department: relationFieldSchema,
// ── Basic info ──
title: z.string().min(1, "Title is required"),
order_date: z.string().optional(),
delivery_date: z.string().optional(),
notes: z.string().optional(),
})
type PurchaseOrderFormValues = z.infer<typeof purchaseOrderFormSchema>
export { purchaseOrderFormSchema, relationFieldSchema }
export type { PurchaseOrderFormValues }
@@ -0,0 +1,319 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery } from "@tanstack/react-query"
import { toast } from "sonner"
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,
RhfTextareaField,
} from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId } from "@/shared/lib/utils"
import { FirstDayOfWork } from "@garage/api"
import { SETTINGS_ROUTES } from "@garage/api"
import { settingsFormSchema, type SettingsFormValues } from "./settings.schema"
// ── Constants ──
const FIRST_DAY_OPTIONS = FirstDayOfWork.map((d) => ({
value: d,
label: d.charAt(0).toUpperCase() + d.slice(1),
}))
const mapLookupOption = (item: any) => ({ value: String(item.id), label: item.name })
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Default values ──
const DEFAULT_VALUES: SettingsFormValues = {
name: "",
email: "",
phone: "",
alternative_phone: "",
website: "",
time_zone: "",
upi_id: "",
first_day_of_work: "",
latitude: "",
longitude: "",
bank_details: "",
first_address_line: "",
second_address_line: "",
country: null,
state: null,
city: "",
zip_code: "",
description: "",
security: "",
privacy_policy: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): SettingsFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
name: d.name ?? "",
email: d.email ?? "",
phone: d.phone ?? "",
alternative_phone: d.alternative_phone ?? "",
website: d.website ?? "",
time_zone: d.time_zone ?? "",
upi_id: d.upi_id ?? "",
first_day_of_work: d.first_day_of_work ?? "",
latitude: d.latitude ?? "",
longitude: d.longitude ?? "",
bank_details: d.bank_details ?? "",
first_address_line: d.first_address_line ?? "",
second_address_line: d.second_address_line ?? "",
country: null,
state: null,
city: d.city ?? "",
zip_code: d.zip_code ?? "",
description: d.description ?? "",
security: d.security ?? "",
privacy_policy: d.privacy_policy ?? "",
}
}
function mapFormToPayload(values: SettingsFormValues) {
return {
name: values.name,
email: values.email || undefined,
phone: values.phone || undefined,
time_zone: values.time_zone || undefined,
first_day_of_work: values.first_day_of_work || undefined,
first_address_line: values.first_address_line || undefined,
country_id: toId(values.country),
city: values.city || undefined,
}
}
// ── Component ──
export function SettingsForm() {
const api = useAuthApi()
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { data, isLoading } = useQuery({
queryKey: [SETTINGS_ROUTES.INDEX],
queryFn: () => api.settings.fetch(),
})
useEffect(() => {
if (!data) return
const raw = (data as any)?.data
const record = Array.isArray(raw) ? raw[0] : raw
if (record) {
form.reset(mapToFormValues(record))
}
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: SettingsFormValues) => {
const payload = mapFormToPayload(values)
const promise = api.settings.update(payload)
toast.promise(promise, {
loading: "Saving settings...",
success: "Settings saved successfully",
error: "Failed to save settings",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save settings</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
{/* General Info */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="name"
label="Workshop Name"
placeholder="My Workshop"
required
disabled={isLoading}
/>
<RhfTextField
name="email"
label="Email"
placeholder="workshop@example.com"
type="email"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="phone"
label="Phone"
placeholder="+971501234567"
type="tel"
disabled={isLoading}
/>
<RhfTextField
name="alternative_phone"
label="Alternative Phone"
placeholder="+971509876543"
type="tel"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="website"
label="Website"
placeholder="https://example.com"
disabled={isLoading}
/>
<RhfTextField
name="upi_id"
label="UPI ID"
placeholder="workshop@upi"
disabled={isLoading}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="time_zone"
label="Time Zone"
placeholder="Asia/Dubai"
disabled={isLoading}
/>
<RhfSelectField
name="first_day_of_work"
label="First Day of Work"
placeholder="Select day"
options={FIRST_DAY_OPTIONS}
disabled={isLoading}
/>
</div>
{/* Address */}
<RhfTextField
name="first_address_line"
label="Address Line 1"
placeholder="Street 10"
disabled={isLoading}
/>
<RhfTextField
name="second_address_line"
label="Address Line 2"
placeholder="Near Central Plaza"
disabled={isLoading}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="country"
label="Country"
placeholder="Select country"
queryKey={["countries"]}
listFn={() => api.geo.countries()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="state"
label="State"
placeholder="Select state"
queryKey={["states"]}
listFn={() => api.geo.states()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="city"
label="City"
placeholder="Dubai"
disabled={isLoading}
/>
<RhfTextField
name="zip_code"
label="Zip Code"
placeholder="00000"
disabled={isLoading}
/>
</div>
{/* Location */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField
name="latitude"
label="Latitude"
placeholder="25.2048"
disabled={isLoading}
/>
<RhfTextField
name="longitude"
label="Longitude"
placeholder="55.2708"
disabled={isLoading}
/>
</div>
{/* Other */}
<RhfTextareaField
name="bank_details"
label="Bank Details"
placeholder="Bank name, account number, IBAN..."
disabled={isLoading}
/>
<RhfTextareaField
name="description"
label="Description"
placeholder="About the workshop..."
disabled={isLoading}
/>
<RhfTextareaField
name="security"
label="Security Policy"
placeholder="Security policy text..."
disabled={isLoading}
/>
<RhfTextareaField
name="privacy_policy"
label="Privacy Policy"
placeholder="Privacy policy text..."
disabled={isLoading}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || isLoading}>
<Save />
{isPending ? "Saving..." : "Save Settings"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,30 @@
import { z } from "zod"
export const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
export const settingsFormSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.union([z.string().email("Invalid email address"), z.literal("")]).optional(),
phone: z.string().optional(),
alternative_phone: z.string().optional(),
website: z.string().optional(),
time_zone: z.string().optional(),
upi_id: z.string().optional(),
first_day_of_work: z.string().optional(),
latitude: z.string().optional(),
longitude: z.string().optional(),
bank_details: z.string().optional(),
first_address_line: z.string().optional(),
second_address_line: z.string().optional(),
country: relationFieldSchema,
state: relationFieldSchema,
city: z.string().optional(),
zip_code: z.string().optional(),
description: z.string().optional(),
security: z.string().optional(),
privacy_policy: z.string().optional(),
})
export type SettingsFormValues = z.infer<typeof settingsFormSchema>
@@ -0,0 +1,24 @@
import { z } from "zod"
export const salesConfigFormSchema = z.object({
sell_rates_tax_inclusive: z.string().optional(),
give_discounts: z.string().optional(),
})
export type SalesConfigFormValues = z.infer<typeof salesConfigFormSchema>
export const purchaseConfigFormSchema = z.object({
purchase_rates_tax_inclusive: z.string().optional(),
receive_discounts: z.string().optional(),
})
export type PurchaseConfigFormValues = z.infer<typeof purchaseConfigFormSchema>
export const generalPreferencesFormSchema = z.object({
sell_rates_tax_inclusive: z.string().optional(),
give_discounts: z.string().optional(),
purchase_rates_tax_inclusive: z.string().optional(),
receive_discounts: z.string().optional(),
})
export type GeneralPreferencesFormValues = z.infer<typeof generalPreferencesFormSchema>
@@ -0,0 +1,102 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { generalPreferencesFormSchema, type GeneralPreferencesFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: GeneralPreferencesFormValues = {
sell_rates_tax_inclusive: "",
give_discounts: "",
purchase_rates_tax_inclusive: "",
receive_discounts: "",
}
export function GeneralPreferencesForm() {
const api = useAuthApi()
const form = useForm<GeneralPreferencesFormValues>({
resolver: zodResolver(generalPreferencesFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: GeneralPreferencesFormValues) => {
const promise = api.configurations.updateGeneralPreferences(values)
toast.promise(promise, {
loading: "Saving general preferences...",
success: "General preferences saved",
error: "Failed to save general preferences",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save general preferences</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="sell_rates_tax_inclusive"
label="Sell Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="give_discounts"
label="Give Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfSelectField
name="purchase_rates_tax_inclusive"
label="Purchase Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="receive_discounts"
label="Receive Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,83 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { purchaseConfigFormSchema, type PurchaseConfigFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: PurchaseConfigFormValues = {
purchase_rates_tax_inclusive: "",
receive_discounts: "",
}
export function PurchaseConfigForm() {
const api = useAuthApi()
const form = useForm<PurchaseConfigFormValues>({
resolver: zodResolver(purchaseConfigFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: PurchaseConfigFormValues) => {
const promise = api.configurations.updatePurchaseTaxDiscount(values)
toast.promise(promise, {
loading: "Saving purchase configuration...",
success: "Purchase configuration saved",
error: "Failed to save purchase configuration",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save purchase configuration</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfSelectField
name="purchase_rates_tax_inclusive"
label="Purchase Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="receive_discounts"
label="Receive Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,83 @@
"use client"
import { AlertTriangle, Save } from "lucide-react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfSelectField } from "@/shared/components/form"
import { useAuthApi } from "@/shared/useApi"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { TaxInclusive, DiscountType } from "@garage/api"
import { salesConfigFormSchema, type SalesConfigFormValues } from "./configurations.schema"
const TAX_INCLUSIVE_OPTIONS = TaxInclusive.map((v) => ({ value: v, label: v }))
const DISCOUNT_OPTIONS = DiscountType.map((v) => ({
value: v,
label: v === "no" ? "No Discount" : v === "line_item_level" ? "Line Item Level" : "Transaction Level",
}))
const DEFAULT_VALUES: SalesConfigFormValues = {
sell_rates_tax_inclusive: "",
give_discounts: "",
}
export function SalesConfigForm() {
const api = useAuthApi()
const form = useForm<SalesConfigFormValues>({
resolver: zodResolver(salesConfigFormSchema) as any,
defaultValues: DEFAULT_VALUES,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: SalesConfigFormValues) => {
const promise = api.configurations.updateSalesTaxDiscount(values)
toast.promise(promise, {
loading: "Saving sales configuration...",
success: "Sales configuration saved",
error: "Failed to save sales configuration",
})
return promise
},
})
return (
<Rhform form={form} onSubmit={(values) => mutate(values)}>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="me-2 h-4 w-4" />
<AlertTitle>Failed to save sales configuration</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfSelectField
name="sell_rates_tax_inclusive"
label="Sell Rates Tax"
placeholder="Select tax type"
options={TAX_INCLUSIVE_OPTIONS}
/>
<RhfSelectField
name="give_discounts"
label="Give Discounts"
placeholder="Select discount type"
options={DISCOUNT_OPTIONS}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isPending}>
<Save />
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,112 @@
"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 } 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 { DEPARTMENT_ROUTES } from "@garage/api"
import { DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS } from "@/modules/services/department-assignment-types"
import { departmentFormSchema, type DepartmentFormValues } from "./department.schema"
export type DepartmentFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: DepartmentFormValues = {
name: "",
assignment_type: "none",
}
function mapToFormValues(data: unknown): DepartmentFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
name: d.name ?? "",
assignment_type: d.assignment_type ?? "none",
}
}
function mapFormToPayload(values: DepartmentFormValues) {
return {
name: values.name,
assignment_type: values.assignment_type || undefined,
}
}
export function DepartmentForm({ resourceId, initialData, onSuccess }: DepartmentFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<DepartmentFormValues, any>({
schema: departmentFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [DEPARTMENT_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: DepartmentFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.departments.update(resourceId, payload)
: api.departments.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating department..." : "Creating department...",
success: isEditing ? "Department updated successfully" : "Department created successfully",
error: isEditing ? "Failed to update department" : "Failed to create department",
})
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 department" : "Failed to create department"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField
name="name"
label="Name"
placeholder="e.g. Mechanical"
required
/>
<RhfSelectField
name="assignment_type"
label="Assignment Type"
placeholder="Select assignment type"
options={[...DEPARTMENT_ASSIGNMENT_TYPE_OPTIONS]}
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Department" : "Create Department")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,8 @@
import { z } from "zod"
export const departmentFormSchema = z.object({
name: z.string().min(1, "Name is required"),
assignment_type: z.string().optional(),
})
export type DepartmentFormValues = z.infer<typeof departmentFormSchema>
@@ -0,0 +1,102 @@
"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 } 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 { INSURANCE_TYPE_ROUTES } from "@garage/api"
import { insuranceTypeFormSchema, type InsuranceTypeFormValues } from "./insurance-type.schema"
export type InsuranceTypeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: InsuranceTypeFormValues = {
title: "",
}
function mapToFormValues(data: unknown): InsuranceTypeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? d.name ?? "",
}
}
function mapFormToPayload(values: InsuranceTypeFormValues) {
return {
title: values.title,
}
}
export function InsuranceTypeForm({ resourceId, initialData, onSuccess }: InsuranceTypeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InsuranceTypeFormValues, any>({
schema: insuranceTypeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [INSURANCE_TYPE_ROUTES.BY_ID, resourceId],
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InsuranceTypeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.insuranceTypes.update(resourceId, payload)
: api.insuranceTypes.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating insurance type..." : "Creating insurance type...",
success: isEditing ? "Insurance type updated successfully" : "Insurance type created successfully",
error: isEditing ? "Failed to update insurance type" : "Failed to create insurance type",
})
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 insurance type" : "Failed to create insurance type"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField
name="title"
label="Title"
placeholder="e.g. Comprehensive"
required
/>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Insurance Type" : "Create Insurance Type")}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,7 @@
import { z } from "zod"
export const insuranceTypeFormSchema = z.object({
title: z.string().min(1, "Title is required"),
})
export type InsuranceTypeFormValues = z.infer<typeof insuranceTypeFormSchema>
@@ -0,0 +1,209 @@
"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery } from "@tanstack/react-query"
import { Car, Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { VEHICLE_ROUTES } from "@garage/api"
import { FieldShell } from "@/shared/components/form/field-shell"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { Badge } from "@/shared/components/ui/badge"
import Image from "next/image"
// ── Vehicle option type (enriched for display) ──
type VehicleOption = {
value: string
label: string
make?: string
model?: string
year?: string
sub_model?: string
license_plate?: string
image_url?: string
mileage?: string
}
function buildVehicleOption(item: any): VehicleOption {
const label =
[item.year, item.make, item.model].filter(Boolean).join(" ") ||
`Vehicle #${item.id}`
return {
value: String(item.id),
label,
make: item.make,
model: item.model,
year: item.year,
sub_model: item.sub_model,
license_plate: item.license_plate,
image_url: item.image_url,
mileage: item.mileage,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
// ── Props ──
export type RhfVehicleSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfVehicleSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Vehicle",
description,
required,
disabled,
placeholder = "Search by make, model, year, or plate...",
}: RhfVehicleSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<VehicleOption[]>({
queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"],
queryFn: async () => {
const res = await api.vehicles.list()
return extractItems(res).map(buildVehicleOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((v) =>
[v.year, v.make, v.model, v.sub_model, v.license_plate]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
// Store only { value, label } to stay compatible with relationFieldSchema
field.onChange(
single ? { value: single.value, label: single.label } : null,
)
}}
disabled={field.disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
// Compare by id string so the selected item highlights correctly
// even when the stored form value has fewer fields than VehicleOption
isItemEqualToValue={(item: VehicleOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
{/* Thumbnail */}
{opt.image_url ? (
<Image
height={60}
width={60}
src={opt.image_url}
alt=""
className="size-9 shrink-0 rounded-md object-cover border border-border"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground border border-border">
<Car className="size-4" />
</div>
)}
{/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{[opt.year, opt.make, opt.model]
.filter(Boolean)
.join(" ")}
{opt.sub_model && (
<span className="ml-1 font-normal text-muted-foreground">
{opt.sub_model}
</span>
)}
</span>
{opt.license_plate && (
<span className="font-mono text-xs text-muted-foreground">
{opt.license_plate}
</span>
)}
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No vehicles found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FieldShell>
)
}
@@ -0,0 +1,5 @@
import { VehicleListResponse } from "@garage/api"
export const getVehicleLabel = (vehicle: VehicleListResponse) => {
return `${vehicle.make} ${vehicle.model} ${vehicle.sub_model} (${vehicle.year} - ${vehicle.license_plate})`
}
@@ -0,0 +1,188 @@
"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,
RhfAsyncSelectField,
RhfSelectField,
RhfTextField,
RhfTextareaField,
} from "@/shared/components/form"
import { useResourceForm } from "@/shared/hooks/use-resource-form"
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
import { toId, toRelation } from "@/shared/lib/utils"
import { useAuthApi } from "@/shared/useApi"
import { BILL_ROUTES, DEPARTMENT_ROUTES, DiscountType, VENDOR_CREDIT_ROUTES, VENDOR_ROUTES, VendorCreditStatus } from "@garage/api"
import { toast } from "sonner"
import { vendorCreditFormSchema, type VendorCreditFormValues } from "./vendor-credit.schema"
export type VendorCreditFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
const DEFAULT_VALUES: VendorCreditFormValues = {
vendor: null,
bill: null,
department: null,
subject: "",
vendor_credit_date: "",
status: "draft",
discount: "no",
notes: "",
}
const STATUS_OPTIONS = VendorCreditStatus.map((value) => ({
value,
label: value.replaceAll("_", " "),
}))
const DISCOUNT_OPTIONS = DiscountType.map((value) => ({
value,
label: value.replaceAll("_", " "),
}))
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? item.bill_number ?? `#${item.id}`,
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
function mapToFormValues(data: unknown): VendorCreditFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
vendor: toRelation(d.vendor_id, d.vendor_name),
bill: toRelation(d.bill_id, d.bill_number ?? d.bill_name),
department: toRelation(d.department_id, d.department_name),
subject: d.subject || "",
vendor_credit_date: d.vendor_credit_date || "",
status: d.status || "draft",
discount: d.discount || "no",
notes: d.notes || "",
}
}
function mapFormToPayload(values: VendorCreditFormValues) {
return {
subject: values.subject,
vendor_id: toId(values.vendor),
bill_id: toId(values.bill),
department_id: toId(values.department),
vendor_credit_date: values.vendor_credit_date || undefined,
status: values.status || undefined,
discount: values.discount || undefined,
notes: values.notes || undefined,
}
}
export function VendorCreditForm({ resourceId, initialData, onSuccess }: VendorCreditFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<VendorCreditFormValues, any>({
schema: vendorCreditFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: VendorCreditFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.vendorCredits.update(resourceId, payload)
: api.vendorCredits.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating vendor credit..." : "Creating vendor credit...",
success: isEditing ? "Vendor credit updated successfully" : "Vendor credit created successfully",
error: isEditing ? "Failed to update vendor credit" : "Failed to create vendor credit",
})
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 vendor credit" : "Failed to create vendor credit"}</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="subject" label="Subject" placeholder="Enter vendor credit subject" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="vendor_credit_date" label="Date" type="date" />
<RhfSelectField name="status" label="Status" options={STATUS_OPTIONS} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="vendor"
label="Vendor"
placeholder="Select vendor"
queryKey={[VENDOR_ROUTES.INDEX]}
listFn={() => api.vendors.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="bill"
label="Bill"
placeholder="Select bill"
queryKey={[BILL_ROUTES.INDEX]}
listFn={() => api.bills.list()}
mapOption={(item: any) => ({ value: String(item.id), label: item.bill_number || item.title || `#${item.id}` })}
{...STORE_OBJECT}
/>
<RhfSelectField name="discount" label="Discount" options={DISCOUNT_OPTIONS} />
<RhfTextareaField name="notes" label="Notes" rows={3} />
<Button type="submit" disabled={isPending}>
{isPending ? (
"Saving..."
) : isEditing ? (
<>
<Save className="me-2 h-4 w-4" />
Update Vendor Credit
</>
) : (
<>
<Plus className="me-2 h-4 w-4" />
Create Vendor Credit
</>
)}
</Button>
</FieldGroup>
</Rhform>
)
}
@@ -0,0 +1,21 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const vendorCreditFormSchema = z.object({
vendor: relationFieldSchema,
bill: relationFieldSchema,
department: relationFieldSchema,
subject: z.string().min(1, "Subject is required"),
vendor_credit_date: z.string().optional(),
status: z.string().optional(),
discount: z.string().optional(),
notes: z.string().optional(),
})
type VendorCreditFormValues = z.infer<typeof vendorCreditFormSchema>
export { vendorCreditFormSchema, relationFieldSchema }
export type { VendorCreditFormValues }