This commit is contained in:
2026-04-07 14:45:29 +03:00
parent c47f7c1b6a
commit 38ef10da4d
11 changed files with 1006 additions and 13 deletions
@@ -0,0 +1,159 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfDateField,
RhfTimeField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { FieldGroup } from "@/shared/components/ui/field"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { DEPARTMENT_ROUTES } from "@garage/api"
import { FUEL_LEVEL_OPTIONS } from "./job-card.schema"
import { toId } from "@/shared/lib/utils"
// ── Schema ──
const checkInSchema = z.object({
check_in_date: z.string().optional(),
check_in_time: z.string().optional(),
km_in: z.string().optional(),
fuel_level: z.string().optional(),
start_date: z.string().optional(),
start_time: z.string().optional(),
department: z.object({ value: z.any(), label: z.string() }).nullable().optional(),
delivery_date: z.string().optional(),
delivery_time: z.string().optional(),
})
type CheckInFormValues = z.infer<typeof checkInSchema>
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { storeObject: true } as const
// ── Props ──
type JobCardCheckInDialogProps = {
jobCardId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
// ── Component ──
export function JobCardCheckInDialog({
jobCardId,
open,
onOpenChange,
onSuccess,
}: JobCardCheckInDialogProps) {
const api = useAuthApi()
const now = new Date()
const todayStr = now.toISOString().split("T")[0]
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
const form = useForm<CheckInFormValues>({
resolver: zodResolver(checkInSchema),
defaultValues: {
check_in_date: todayStr,
check_in_time: currentTime,
km_in: "",
fuel_level: "",
start_date: "",
start_time: "",
department: null,
delivery_date: "",
delivery_time: "",
},
})
const handleSubmit = async (values: CheckInFormValues) => {
try {
await api.jobCards.checkIn(jobCardId, {
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,
start_date: values.start_date || undefined,
start_time: values.start_time || undefined,
department_id: values.department ? Number(toId(values.department)) : undefined,
delivery_date: values.delivery_date || undefined,
delivery_time: values.delivery_time || undefined,
})
toast.success("Job card checked in successfully")
form.reset()
onSuccess?.()
} catch {
toast.error("Failed to check in job card")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Check In</DialogTitle>
</DialogHeader>
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<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" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_in" label="KM In" type="number" placeholder="e.g. 50321" />
<RhfSelectField name="fuel_level" label="Fuel Level" options={FUEL_LEVEL_OPTIONS} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="start_date" label="Start Date" />
<RhfTimeField name="start_time" label="Start Time" />
</div>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(op:any)=> ({value: op.id, label: op.name})}
{...STORE_OBJECT}
getOptionLabel={op=>op.label}
getOptionValue={op=>op}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="delivery_date" label="Delivery Date" />
<RhfTimeField name="delivery_time" label="Delivery Time" />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Checking in..." : "Check In"}
</Button>
</div>
</FieldGroup>
</Rhform>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,139 @@
"use client"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { useAuthApi } from "@/shared/useApi"
import {
Rhform,
RhfTextField,
RhfSelectField,
RhfDateField,
RhfTimeField,
RhfAsyncSelectField,
} from "@/shared/components/form"
import { FieldGroup } from "@/shared/components/ui/field"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { DEPARTMENT_ROUTES } from "@garage/api"
import { FUEL_LEVEL_OPTIONS } from "./job-card.schema"
import { toId } from "@/shared/lib/utils"
// ── Schema ──
const deliverySchema = z.object({
check_out_date: z.string().optional(),
check_out_time: z.string().optional(),
km_out: z.string().optional(),
fuel_level: z.string().optional(),
department: z.object({ value: z.number(), label: z.string() }).nullable().optional(),
})
type DeliveryFormValues = z.infer<typeof deliverySchema>
const mapLookupOption = (item: any) => ({
value: String(item.id),
label: item.name,
})
const STORE_OBJECT = { storeObject: true } as const
// ── Props ──
type JobCardDeliveryDialogProps = {
jobCardId: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
// ── Component ──
export function JobCardDeliveryDialog({
jobCardId,
open,
onOpenChange,
onSuccess,
}: JobCardDeliveryDialogProps) {
const api = useAuthApi()
const now = new Date()
const todayStr = now.toISOString().split("T")[0]
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
const form = useForm<DeliveryFormValues>({
resolver: zodResolver(deliverySchema),
defaultValues: {
check_out_date: todayStr,
check_out_time: currentTime,
km_out: "",
fuel_level: "",
department: null,
},
})
const handleSubmit = async (values: DeliveryFormValues) => {
try {
await api.jobCards.delivery(jobCardId, {
check_out_date: values.check_out_date || undefined,
check_out_time: values.check_out_time || undefined,
km_out: values.km_out ? Number(values.km_out) : undefined,
fuel_level: values.fuel_level || undefined,
department_id: values.department ? Number(toId(values.department as any)) : undefined,
})
toast.success("Job card marked as delivered successfully")
form.reset()
onSuccess?.()
} catch {
toast.error("Failed to mark job card as delivered")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Delivery</DialogTitle>
</DialogHeader>
<Rhform form={form} onSubmit={handleSubmit}>
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfDateField name="check_out_date" label="Check-out Date" />
<RhfTimeField name="check_out_time" label="Check-out Time" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<RhfTextField name="km_out" label="KM Out" type="number" placeholder="e.g. 50480" />
<RhfSelectField name="fuel_level" label="Fuel Level" options={FUEL_LEVEL_OPTIONS} />
</div>
<RhfAsyncSelectField
name="department"
label="Department"
placeholder="Select department"
queryKey={[DEPARTMENT_ROUTES.INDEX]}
listFn={() => api.departments.list()}
mapOption={(op: any) => ({ value: op.id, label: op.name })}
{...STORE_OBJECT}
getOptionLabel={op => op.label}
getOptionValue={op => op}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Delivering..." : "Mark as Delivered"}
</Button>
</div>
</FieldGroup>
</Rhform>
</DialogContent>
</Dialog>
)
}
@@ -28,6 +28,7 @@ import { CrudShowResponse, JobCardsClient, PAYMENT_RECEIVED_ROUTES, PaymentRecei
import { ResourcePage } from "@/shared/data-view/resource-page"
import PaymentReceivedPage from "@/app/(authenticated)/sales/payment-received/page"
import JobCardPaymentsReceived from "./job-card-payments-received"
import { formatDate } from "@/shared/utils/formatters"
@@ -116,7 +117,7 @@ export function JobCardGeneralInfo({ jobCard }: { jobCard: JobCard }) {
<InfoItem
icon={Calendar}
label="Check-in Date"
value={jobCard.check_in_date}
value={formatDate(jobCard.check_in_date)}
/>
<InfoItem
icon={Gauge}
@@ -1,5 +1,6 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
@@ -20,6 +21,8 @@ import {
PackageCheck,
CheckCircle2,
} from "lucide-react"
import { JobCardCheckInDialog } from "./job-card-check-in-dialog"
import { JobCardDeliveryDialog } from "./job-card-delivery-dialog"
// ── Status icon mapping ──
@@ -45,6 +48,9 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
const currentIndex = JOB_CARD_STATUSES.findIndex((s) => s.value === currentStatus)
const [checkInOpen, setCheckInOpen] = useState(false)
const [deliveryOpen, setDeliveryOpen] = useState(false)
const { mutate, isPending, variables } = useMutation({
mutationFn: async (status: JobCardStatus) => {
const promise = api.jobCards.changeStatus(jobCardId, { status })
@@ -60,9 +66,17 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
},
})
const handleClick = (status: JobCardStatus, index: number) => {
const handleClick = (status: JobCardStatus) => {
if (isPending) return
if (status === currentStatus) return
if (status === "check_in") {
setCheckInOpen(true)
return
}
if (status === "delivered") {
setDeliveryOpen(true)
return
}
mutate(status)
}
@@ -82,7 +96,7 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleClick(step.value, index)}
onClick={() => handleClick(step.value)}
disabled={!isClickable}
className={cn(
"relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all",
@@ -120,6 +134,26 @@ export function JobCardStatusStepper({ jobCardId }: JobCardStatusStepperProps) {
)
})}
</div>
<JobCardCheckInDialog
jobCardId={jobCardId}
open={checkInOpen}
onOpenChange={setCheckInOpen}
onSuccess={() => {
setCheckInOpen(false)
;(jobCard as any)?.setStatus("check_in")
}}
/>
<JobCardDeliveryDialog
jobCardId={jobCardId}
open={deliveryOpen}
onOpenChange={setDeliveryOpen}
onSuccess={() => {
setDeliveryOpen(false)
;(jobCard as any)?.setStatus("delivered")
}}
/>
</TooltipProvider>
)
}