This commit is contained in:
Mohammad Khyata 2026-03-27 13:44:02 +03:00
parent 4b0aef983b
commit 1c7dc2cdd3
17 changed files with 1111 additions and 3 deletions

0
.turbo/turbo-build.log Normal file
View File

View File

@ -0,0 +1,65 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { EmployeeForm } from "@/modules/employees/employee-form"
import { EMPLOYEE_ROUTES } from "@repo/api"
import type { EmployeesClient } from "@repo/api"
export default function EmployeesPage() {
return (
<ResourcePage<EmployeesClient>
pageTitle="Employees"
title="Employee"
routeKey={EMPLOYEE_ROUTES.INDEX}
getClient={(api) => api.employees}
columns={({ actionsColumn }) => [
{
accessorKey: "first_name",
header: ({ column }) => <ColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const { first_name, last_name } = row.original
return `${first_name ?? ""} ${last_name ?? ""}`.trim()
},
},
{
accessorKey: "email",
header: ({ column }) => <ColumnHeader column={column} title="Email" />,
},
{
accessorKey: "phone",
header: ({ column }) => <ColumnHeader column={column} title="Phone" />,
},
{
accessorKey: "position",
header: ({ column }) => <ColumnHeader column={column} title="Position" />,
},
{
accessorKey: "department",
header: ({ column }) => <ColumnHeader column={column} title="Department" />,
cell: ({ row }) => (row.original as any).department?.name ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = row.original.status
return (
<span className={status === "active" ? "text-green-600" : "text-red-600"}>
{status}
</span>
)
},
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<EmployeeForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,50 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ShopCalendarForm } from "@/modules/shop-calendars/shop-calendar-form"
import { SHOP_CALENDAR_ROUTES } from "@repo/api"
import type { ShopCalendarsClient } from "@repo/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopCalendarsPage() {
return (
<ResourcePage<ShopCalendarsClient>
pageTitle="Shop Calendars"
title="Shop Calendar"
routeKey={SHOP_CALENDAR_ROUTES.INDEX}
getClient={(api) => api.shopCalendars}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
(row.original as any).is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
{
accessorKey: "shop_calender_days",
header: () => <span>Days</span>,
enableSorting: false,
cell: ({ row }) => {
const days = (row.original as any).shop_calender_days
return days?.length ?? 0
},
},
actionsColumn({ onEdit: undefined }),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopCalendarForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,57 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { ShopTimingForm } from "@/modules/shop-timings/shop-timing-form"
import { SHOP_TIMING_ROUTES } from "@repo/api"
import type { ShopTimingsClient } from "@repo/api"
import { CheckCircle2Icon } from "lucide-react"
export default function ShopTimingsPage() {
return (
<ResourcePage<ShopTimingsClient>
pageTitle="Shop Timings"
title="Shop Timing"
routeKey={SHOP_TIMING_ROUTES.INDEX}
getClient={(api) => api.shopTimings}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "in_time",
header: ({ column }) => <ColumnHeader column={column} title="In Time" />,
},
{
accessorKey: "out_time",
header: ({ column }) => <ColumnHeader column={column} title="Out Time" />,
},
{
accessorKey: "full_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Full Day Hours" />,
},
{
accessorKey: "half_day_hours",
header: ({ column }) => <ColumnHeader column={column} title="Half Day Hours" />,
},
{
accessorKey: "is_default",
header: ({ column }) => <ColumnHeader column={column} title="Default" />,
cell: ({ row }) =>
row.original.is_default ? (
<CheckCircle2Icon className="text-green-600 h-5 w-5" />
) : null,
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<ShopTimingForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { ResourcePage } from "@/shared/data-view/resource-page"
import { ColumnHeader } from "@/shared/data-view/table-view"
import { InspectionForm } from "@/modules/inspections/inspection-form"
import { INSPECTION_ROUTES } from "@repo/api"
import type { InspectionsClient } from "@repo/api"
export default function InspectionsPage() {
return (
<ResourcePage<InspectionsClient>
pageTitle="Inspections"
title="Inspection"
routeKey={INSPECTION_ROUTES.INDEX}
getClient={(api) => api.inspections}
columns={({ actionsColumn }) => [
{
accessorKey: "title",
header: ({ column }) => <ColumnHeader column={column} title="Title" />,
},
{
accessorKey: "customer",
header: ({ column }) => <ColumnHeader column={column} title="Customer" />,
cell: ({ row }) => {
const c = (row.original as any).customer
return c ? `${c.first_name ?? ""} ${c.last_name ?? ""}`.trim() : "—"
},
},
{
accessorKey: "vehicle",
header: ({ column }) => <ColumnHeader column={column} title="Vehicle" />,
cell: ({ row }) => {
const v = (row.original as any).vehicle
return v ? `${v.make ?? ""} ${v.model ?? ""}`.trim() : "—"
},
},
{
accessorKey: "inspection_category",
header: ({ column }) => <ColumnHeader column={column} title="Category" />,
cell: ({ row }) => (row.original as any).inspection_category?.name ?? "—",
},
{
accessorKey: "status",
header: ({ column }) => <ColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = (row.original as any).status
return (
<span className={status === "completed" ? "text-green-600" : "text-yellow-600"}>
{status ?? "—"}
</span>
)
},
},
actionsColumn(),
]}
renderForm={({ resourceId, initialData, onSuccess }) => (
<InspectionForm
resourceId={resourceId}
initialData={initialData}
onSuccess={onSuccess}
/>
)}
/>
)
}

View File

@ -14,6 +14,8 @@ export default function VehiclesPage() {
title="Vehicle" title="Vehicle"
routeKey={VEHICLE_ROUTES.INDEX} routeKey={VEHICLE_ROUTES.INDEX}
getClient={(api) => api.vehicles} getClient={(api) => api.vehicles}
columns={({ actionsColumn }) => [ columns={({ actionsColumn }) => [
{ {
accessorKey: "name", accessorKey: "name",

View File

@ -213,7 +213,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed:
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5"> <SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">
<Link href={sub.href}> <Link href={sub.href}>
{sub.icon ? ( {sub.icon ? (
<span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-foreground")}> <span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-primary")}>
{sub.icon} {sub.icon}
</span> </span>
) : ( ) : (
@ -222,7 +222,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed:
"size-1.5 transition-colors duration-200", "size-1.5 transition-colors duration-200",
isSubActive isSubActive
? "fill-primary text-primary" ? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-foreground" : "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-primary"
)} )}
/> />
)} )}

View File

@ -0,0 +1,236 @@
"use client"
import { AlertTriangle, Plus, Save } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
import { FieldGroup } from "@/shared/components/ui/field"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfAsyncSelectField,
RhfCheckboxField,
} 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 {
employeeFormSchema,
type EmployeeFormValues,
} from "./employee.schema"
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES } from "@repo/api"
// ── Constants ──
const STATUS_OPTIONS = [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]
const TYPE_OPTIONS = [
{ value: "employee", label: "Employee" },
{ value: "contractor", label: "Contractor" },
]
// ── Props ──
export type EmployeeFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: EmployeeFormValues = {
department: null,
shop_calender: null,
shop_timing: null,
first_name: "",
last_name: "",
email: "",
phone: "",
position: "",
status: "active",
type: "employee",
track_attendance: true,
notify_owner_when_punch_in_out: false,
geo_fence_radius: 100,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): EmployeeFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
department: toRelation(d.department_id, d.department?.name),
shop_calender: toRelation(d.shop_calender_id, d.shop_calender?.title),
shop_timing: toRelation(d.shop_timing_id, d.shop_timing?.title),
first_name: d.first_name || "",
last_name: d.last_name || "",
email: d.email || "",
phone: d.phone || "",
position: d.position || "",
status: d.status || "active",
type: d.type || "employee",
track_attendance: d.track_attendance ?? true,
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false,
geo_fence_radius: d.geo_fence_radius ?? 100,
}
}
function mapFormToPayload(values: EmployeeFormValues) {
return {
department_id: toId(values.department),
shop_calender_id: toId(values.shop_calender),
shop_timing_id: toId(values.shop_timing),
first_name: values.first_name,
last_name: values.last_name,
email: values.email || undefined,
phone: values.phone || undefined,
position: values.position || undefined,
status: values.status || undefined,
type: values.type || undefined,
track_attendance: values.track_attendance,
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out,
geo_fence_radius: values.geo_fence_radius,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name ?? item.title ?? String(item.id),
})
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
// ── Component ──
export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<EmployeeFormValues, any>({
schema: employeeFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.employees.show(id),
queryKey: [EMPLOYEE_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: EmployeeFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.employees.update(resourceId, payload)
: api.employees.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating employee..." : "Creating employee...",
success: isEditing ? "Employee updated successfully" : "Employee created successfully",
error: isEditing ? "Failed to update employee" : "Failed to create employee",
})
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 employee" : "Failed to create employee"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="first_name" label="First Name" placeholder="Jane" required />
<RhfTextField name="last_name" label="Last Name" placeholder="Smith" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" />
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="position" label="Position" placeholder="Technician" />
<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">
<RhfSelectField
name="status"
label="Status"
placeholder="Select status"
options={STATUS_OPTIONS}
/>
<RhfSelectField
name="type"
label="Type"
placeholder="Select type"
options={TYPE_OPTIONS}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="shop_calender"
label="Shop Calendar"
placeholder="Select calendar"
queryKey={[SHOP_CALENDAR_ROUTES.INDEX]}
listFn={() => api.shopCalendars.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="shop_timing"
label="Shop Timing"
placeholder="Select timing"
queryKey={[SHOP_TIMING_ROUTES.INDEX]}
listFn={() => api.shopTimings.list()}
mapOption={mapLookupOption}
{...STORE_OBJECT}
/>
</div>
<RhfTextField name="geo_fence_radius" label="Geo Fence Radius (m)" placeholder="100" type="number" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfCheckboxField name="track_attendance" label="Track Attendance" />
<RhfCheckboxField name="notify_owner_when_punch_in_out" label="Notify Owner on Punch In/Out" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Employee" : "Create Employee")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,32 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const STATUS_OPTIONS = ["active", "inactive"] as const
const TYPE_OPTIONS = ["employee", "contractor"] as const
const employeeFormSchema = z.object({
department: relationFieldSchema,
shop_calender: relationFieldSchema,
shop_timing: relationFieldSchema,
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
email: z.union([
z.string().email("Enter a valid email address"),
z.literal(""),
]).optional(),
phone: z.string().optional(),
position: z.string().optional(),
status: z.string().optional(),
type: z.string().optional(),
track_attendance: z.boolean(),
notify_owner_when_punch_in_out: z.boolean(),
geo_fence_radius: z.coerce.number().min(0).optional(),
})
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS }
export type { EmployeeFormValues }

View File

@ -0,0 +1,57 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FieldGroup } from "@/shared/components/ui/field"
import { Rhform, RhfTextField, type InlineCreateFormProps } from "@/shared/components/form"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
const schema = z.object({
inspection_name: z.string().min(1, "Name is required"),
})
type FormValues = z.infer<typeof schema>
export function InspectionCategoryInlineForm({ onSuccess }: InlineCreateFormProps) {
const api = useAuthApi()
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { inspection_name: "" },
})
const handleSubmit = async (values: FormValues) => {
try {
const result = await api.inspections.createCategory({
inspection_name: values.inspection_name,
})
toast.success("Inspection category created")
form.reset()
const item = (result as any)?.data ?? result
onSuccess({ value: String(item.id), label: item.name ?? values.inspection_name })
} catch {
toast.error("Failed to create inspection category")
}
}
return (
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<RhfTextField
name="inspection_name"
label="Name"
placeholder="e.g. Brake Check"
required
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Plus />
{form.formState.isSubmitting ? "Creating..." : "Create Category"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,231 @@
"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,
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 { InspectionCategoryInlineForm } from "./inline-forms/inspection-category-inline-form"
import { DepartmentInlineForm } from "@/modules/services/inline-forms/department-inline-form"
import {
inspectionFormSchema,
type InspectionFormValues,
} from "./inspection.schema"
import {
INSPECTION_ROUTES,
CUSTOMER_ROUTES,
VEHICLE_ROUTES,
DEPARTMENT_ROUTES,
EMPLOYEE_ROUTES,
} from "@repo/api"
// ── Props ──
export type InspectionFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: InspectionFormValues = {
customer: null,
vehicle: null,
department: null,
inspection_category: null,
employee: null,
title: "",
order_number: "",
date: "",
time: "",
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): InspectionFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
customer: toRelation(d.customer_id, d.customer?.first_name ? `${d.customer.first_name} ${d.customer.last_name ?? ""}`.trim() : undefined),
vehicle: toRelation(d.vehicle_id, d.vehicle?.make ? `${d.vehicle.make} ${d.vehicle.model ?? ""}`.trim() : undefined),
department: toRelation(d.department_id, d.department?.name),
inspection_category: toRelation(d.inspection_category_id, d.inspection_category?.name),
employee: toRelation(d.employee_id, d.employee?.first_name ? `${d.employee.first_name} ${d.employee.last_name ?? ""}`.trim() : undefined),
title: d.title ?? "",
order_number: d.order_number ?? "",
date: d.date ?? "",
time: d.time ?? "",
}
}
function mapFormToPayload(values: InspectionFormValues) {
return {
customer_id: toId(values.customer),
vehicle_id: toId(values.vehicle),
department_id: toId(values.department),
inspection_category_id: toId(values.inspection_category),
employee_id: toId(values.employee),
title: values.title,
order_number: values.order_number || undefined,
date: values.date || undefined,
time: values.time || undefined,
}
}
// ── Shared mapOption for async selects ──
const mapLookupOption = (item: any) => ({
value: String(item.id),
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 ──
export function InspectionForm({ resourceId, initialData, onSuccess }: InspectionFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<InspectionFormValues, any>({
schema: inspectionFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
queryKey: [INSPECTION_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: InspectionFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.inspections.update(resourceId, payload)
: api.inspections.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating inspection..." : "Creating inspection...",
success: isEditing ? "Inspection updated successfully" : "Inspection created successfully",
error: isEditing ? "Failed to update inspection" : "Failed to create inspection",
})
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 inspection" : "Failed to create inspection"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<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}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={mapLookupOption}
createForm={(props) => <DepartmentInlineForm {...props} />}
createLabel="Department"
{...STORE_OBJECT}
/>
<RhfAsyncSelectField
name="inspection_category"
label="Inspection Category"
placeholder="Select category"
queryKey={[INSPECTION_ROUTES.CATEGORIES]}
listFn={() => api.inspections.listCategories()}
mapOption={mapLookupOption}
createForm={(props) => <InspectionCategoryInlineForm {...props} />}
createLabel="Inspection Category"
{...STORE_OBJECT}
/>
</div>
<RhfAsyncSelectField
name="employee"
label="Employee"
placeholder="Select employee"
queryKey={[EMPLOYEE_ROUTES.INDEX]}
listFn={() => api.employees.list()}
mapOption={mapEmployeeOption}
{...STORE_OBJECT}
/>
<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" />
</div>
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Inspection" : "Create Inspection")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,22 @@
import { z } from "zod"
const relationFieldSchema = z
.object({ value: z.string(), label: z.string() })
.nullable()
const inspectionFormSchema = z.object({
customer: relationFieldSchema,
vehicle: relationFieldSchema,
department: relationFieldSchema,
inspection_category: relationFieldSchema,
employee: relationFieldSchema,
title: z.string().min(1, "Title is required"),
order_number: z.string().optional(),
date: z.string().optional(),
time: z.string().optional(),
})
type InspectionFormValues = z.infer<typeof inspectionFormSchema>
export { inspectionFormSchema, relationFieldSchema }
export type { InspectionFormValues }

View File

@ -0,0 +1,99 @@
"use client"
import { AlertTriangle, Plus } 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,
RhfCheckboxField,
} 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 {
shopCalendarFormSchema,
type ShopCalendarFormValues,
} from "./shop-calendar.schema"
import { SHOP_CALENDAR_ROUTES } from "@repo/api"
// ── Props ──
export type ShopCalendarFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopCalendarFormValues = {
title: "",
is_default: false,
}
// ── Component ──
export function ShopCalendarForm({ resourceId, onSuccess }: ShopCalendarFormProps) {
const api = useAuthApi()
const { form } = useResourceForm<ShopCalendarFormValues, any>({
schema: shopCalendarFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId: null,
queryKey: [SHOP_CALENDAR_ROUTES.INDEX],
mapToFormValues: (data: unknown) => {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
is_default: d.is_default ?? false,
}
},
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopCalendarFormValues) => {
const payload = {
title: values.title,
is_default: values.is_default,
}
const promise = api.shopCalendars.create(payload)
toast.promise(promise, {
loading: "Creating shop calendar...",
success: "Shop calendar created successfully",
error: "Failed to create shop calendar",
})
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>Failed to create shop calendar</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter calendar title" required />
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
<Plus />
{isPending ? "Creating..." : "Create Shop Calendar"}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,11 @@
import { z } from "zod"
const shopCalendarFormSchema = z.object({
title: z.string().min(1, "Title is required"),
is_default: z.boolean(),
})
type ShopCalendarFormValues = z.infer<typeof shopCalendarFormSchema>
export { shopCalendarFormSchema }
export type { ShopCalendarFormValues }

View File

@ -0,0 +1,161 @@
"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,
RhfCheckboxField,
} 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 {
shopTimingFormSchema,
type ShopTimingFormValues,
} from "./shop-timing.schema"
import { SHOP_TIMING_ROUTES } from "@repo/api"
// ── Props ──
export type ShopTimingFormProps = {
resourceId?: string | null
initialData?: unknown
onSuccess?: () => void
}
// ── Default values ──
const DEFAULT_VALUES: ShopTimingFormValues = {
title: "",
in_time: "",
out_time: "",
full_day_hours: "",
half_day_hours: "",
punch_in: "",
punch_out: "",
before_time: "",
after_time: "",
is_default: false,
}
// ── Mapping helpers ──
function mapToFormValues(data: unknown): ShopTimingFormValues {
const d = (data as any)?.data ?? data ?? {}
return {
title: d.title ?? "",
in_time: d.in_time ?? "",
out_time: d.out_time ?? "",
full_day_hours: d.full_day_hours ?? "",
half_day_hours: d.half_day_hours ?? "",
punch_in: d.punch_in ?? "",
punch_out: d.punch_out ?? "",
before_time: d.before_time ?? "",
after_time: d.after_time ?? "",
is_default: d.is_default ?? false,
}
}
function mapFormToPayload(values: ShopTimingFormValues) {
return {
title: values.title,
in_time: values.in_time,
out_time: values.out_time,
full_day_hours: values.full_day_hours || undefined,
half_day_hours: values.half_day_hours || undefined,
punch_in: values.punch_in || undefined,
punch_out: values.punch_out || undefined,
before_time: values.before_time || undefined,
after_time: values.after_time || undefined,
is_default: values.is_default,
}
}
// ── Component ──
export function ShopTimingForm({ resourceId, initialData, onSuccess }: ShopTimingFormProps) {
const api = useAuthApi()
const { form, isEditing } = useResourceForm<ShopTimingFormValues, any>({
schema: shopTimingFormSchema,
defaultValues: DEFAULT_VALUES,
resourceId,
initialData,
initialize: (id) => api.shopTimings.show(id),
queryKey: [SHOP_TIMING_ROUTES.BY_ID, resourceId],
mapToFormValues: mapToFormValues,
})
const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: ShopTimingFormValues) => {
const payload = mapFormToPayload(values)
const promise = isEditing && resourceId
? api.shopTimings.update(resourceId, payload)
: api.shopTimings.create(payload)
toast.promise(promise, {
loading: isEditing ? "Updating shop timing..." : "Creating shop timing...",
success: isEditing ? "Shop timing updated successfully" : "Shop timing created successfully",
error: isEditing ? "Failed to update shop timing" : "Failed to create shop timing",
})
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 shop timing" : "Failed to create shop timing"}
</AlertTitle>
{error.message}
</Alert>
)}
<FieldGroup>
<RhfTextField name="title" label="Title" placeholder="Enter title" required />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="in_time" label="In Time" placeholder="HH:MM:SS" required />
<RhfTextField name="out_time" label="Out Time" placeholder="HH:MM:SS" required />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="full_day_hours" label="Full Day Hours" placeholder="HH:MM:SS" />
<RhfTextField name="half_day_hours" label="Half Day Hours" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="punch_in" label="Punch In" placeholder="HH:MM:SS" />
<RhfTextField name="punch_out" label="Punch Out" placeholder="HH:MM:SS" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="before_time" label="Before Time" placeholder="HH:MM:SS" />
<RhfTextField name="after_time" label="After Time" placeholder="HH:MM:SS" />
</div>
<RhfCheckboxField name="is_default" label="Set as default" />
<Button type="submit" variant="default" disabled={isPending}>
{isEditing ? <Save /> : <Plus />}
{isPending
? (isEditing ? "Updating..." : "Creating...")
: (isEditing ? "Update Shop Timing" : "Create Shop Timing")}
</Button>
</FieldGroup>
</Rhform>
)
}

View File

@ -0,0 +1,19 @@
import { z } from "zod"
const shopTimingFormSchema = z.object({
title: z.string().min(1, "Title is required"),
in_time: z.string().min(1, "In time is required"),
out_time: z.string().min(1, "Out time is required"),
full_day_hours: z.string().optional(),
half_day_hours: z.string().optional(),
punch_in: z.string().optional(),
punch_out: z.string().optional(),
before_time: z.string().optional(),
after_time: z.string().optional(),
is_default: z.boolean().default(false),
})
type ShopTimingFormValues = z.infer<typeof shopTimingFormSchema>
export { shopTimingFormSchema }
export type { ShopTimingFormValues }

View File

@ -6,10 +6,11 @@ import {
parseAsStringEnum, parseAsStringEnum,
createSearchParamsCache, createSearchParamsCache,
} from "nuqs/server" } from "nuqs/server"
import { DEFAULT_PER_PAGE } from "@repo/api"
export const dataTableSearchParams = { export const dataTableSearchParams = {
page: parseAsInteger.withDefault(1), page: parseAsInteger.withDefault(1),
per_page: parseAsInteger.withDefault(10), per_page: parseAsInteger.withDefault(DEFAULT_PER_PAGE),
sort_by: parseAsString, sort_by: parseAsString,
sort_order: parseAsStringEnum(["asc", "desc"] as const), sort_order: parseAsStringEnum(["asc", "desc"] as const),
} }