590 lines
22 KiB
TypeScript
590 lines
22 KiB
TypeScript
"use client"
|
|
|
|
import { use, useState, useRef, useCallback } from "react"
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
|
|
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/components/ui/dropdown-menu"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/shared/components/ui/dialog"
|
|
import { Input } from "@/shared/components/ui/input"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { Textarea } from "@/shared/components/ui/textarea"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/shared/components/ui/select"
|
|
import { toast } from "sonner"
|
|
import {
|
|
Plus,
|
|
Ellipsis,
|
|
Pencil,
|
|
Trash2,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
XCircle,
|
|
MinusCircle,
|
|
CircleDot,
|
|
Paperclip,
|
|
FileUp,
|
|
FileText,
|
|
FileImage,
|
|
File,
|
|
X,
|
|
} from "lucide-react"
|
|
import { INSPECTION_ROUTES } from "@garage/api"
|
|
|
|
// ── Types ──
|
|
|
|
type CheckpointItem = {
|
|
id: number
|
|
inspection_id?: number
|
|
name?: string
|
|
description?: string
|
|
record_type?: string
|
|
condition_rate?: number
|
|
file?: string
|
|
status?: string
|
|
created_at?: string
|
|
updated_at?: string
|
|
}
|
|
|
|
// ── Constants ──
|
|
|
|
const CHECKPOINT_STATUSES = [
|
|
{ value: "passed", label: "Passed", icon: CheckCircle2, color: "bg-green-100 text-green-800" },
|
|
{ value: "need_attention", label: "Need Attention", icon: AlertTriangle, color: "bg-yellow-100 text-yellow-800" },
|
|
{ value: "failed", label: "Failed", icon: XCircle, color: "bg-red-100 text-red-800" },
|
|
{ value: "not_applicable", label: "Not Applicable", icon: MinusCircle, color: "bg-gray-100 text-gray-800" },
|
|
{ value: "not_inspected", label: "Not Inspected", icon: CircleDot, color: "bg-blue-100 text-blue-800" },
|
|
] as const
|
|
|
|
const RECORD_TYPES = [
|
|
{ value: "record_conditions", label: "Record Conditions" },
|
|
{ value: "record_audio", label: "Record Audio" },
|
|
{ value: "record_video", label: "Record Video" },
|
|
{ value: "capture_photo", label: "Capture Photo" },
|
|
] as const
|
|
|
|
function formatStatus(status?: string) {
|
|
if (!status) return "Not Inspected"
|
|
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
function getStatusConfig(status?: string) {
|
|
return CHECKPOINT_STATUSES.find((s) => s.value === status) || CHECKPOINT_STATUSES[4]
|
|
}
|
|
|
|
// ── Checkpoint Form Dialog ──
|
|
|
|
function CheckpointFormDialog({
|
|
open,
|
|
onOpenChange,
|
|
inspectionId,
|
|
checkpoint,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
inspectionId: string
|
|
checkpoint?: CheckpointItem | null
|
|
onSuccess: () => void
|
|
}) {
|
|
const api = useAuthApi()
|
|
const [name, setName] = useState(checkpoint?.name ?? "")
|
|
const [description, setDescription] = useState(checkpoint?.description ?? "")
|
|
const [recordType, setRecordType] = useState(checkpoint?.record_type ?? "record_conditions")
|
|
const [isPending, setIsPending] = useState(false)
|
|
|
|
const isEditing = !!checkpoint
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!name.trim()) {
|
|
toast.error("Name is required")
|
|
return
|
|
}
|
|
setIsPending(true)
|
|
try {
|
|
const payload = {
|
|
inspection_id: Number(inspectionId),
|
|
name: name.trim(),
|
|
description: description.trim() || undefined,
|
|
record_type: recordType,
|
|
}
|
|
|
|
if (isEditing) {
|
|
const promise = api.inspections.updateCheckpoint(String(checkpoint.id), payload)
|
|
toast.promise(promise, {
|
|
loading: "Updating checkpoint...",
|
|
success: "Checkpoint updated",
|
|
error: "Failed to update checkpoint",
|
|
})
|
|
await promise
|
|
} else {
|
|
const promise = api.inspections.createCheckpoint(payload)
|
|
toast.promise(promise, {
|
|
loading: "Creating checkpoint...",
|
|
success: "Checkpoint created",
|
|
error: "Failed to create checkpoint",
|
|
})
|
|
await promise
|
|
}
|
|
onSuccess()
|
|
onOpenChange(false)
|
|
} finally {
|
|
setIsPending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditing ? "Edit Checkpoint" : "Add Checkpoint"}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cp-name">Name *</Label>
|
|
<Input
|
|
id="cp-name"
|
|
placeholder="e.g. Engine Oil Level"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cp-description">Description</Label>
|
|
<Textarea
|
|
id="cp-description"
|
|
placeholder="Check oil level and condition"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cp-record-type">Record Type</Label>
|
|
<Select value={recordType} onValueChange={setRecordType}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{RECORD_TYPES.map((rt) => (
|
|
<SelectItem key={rt.value} value={rt.value}>
|
|
{rt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button type="submit" disabled={isPending}>
|
|
{isEditing ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
|
{isPending
|
|
? (isEditing ? "Updating..." : "Creating...")
|
|
: (isEditing ? "Update Checkpoint" : "Create Checkpoint")}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Attachments Dialog ──
|
|
|
|
function getFileIcon(url: string) {
|
|
const ext = url.split(".").pop()?.toLowerCase()
|
|
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")) return FileImage
|
|
if (ext === "pdf") return FileText
|
|
return File
|
|
}
|
|
|
|
function getFileName(url: string) {
|
|
try {
|
|
return decodeURIComponent(url.split("/").pop() ?? "Attachment")
|
|
} catch {
|
|
return url.split("/").pop() ?? "Attachment"
|
|
}
|
|
}
|
|
|
|
function isImageUrl(url: string) {
|
|
const ext = url.split(".").pop()?.toLowerCase()
|
|
return ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext ?? "")
|
|
}
|
|
|
|
function CheckpointAttachmentsDialog({
|
|
open,
|
|
onOpenChange,
|
|
checkpoint,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
checkpoint: CheckpointItem | null
|
|
onSuccess: () => void
|
|
}) {
|
|
const api = useAuthApi()
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
|
|
const handleUpload = useCallback(async (file: globalThis.File) => {
|
|
if (!checkpoint) return
|
|
setIsUploading(true)
|
|
try {
|
|
const promise = api.inspections.uploadCheckpointMedia(
|
|
String(checkpoint.id),
|
|
{ file },
|
|
)
|
|
toast.promise(promise, {
|
|
loading: "Uploading attachment...",
|
|
success: "Attachment uploaded",
|
|
error: "Failed to upload attachment",
|
|
})
|
|
await promise
|
|
onSuccess()
|
|
} finally {
|
|
setIsUploading(false)
|
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
|
}
|
|
}, [api, checkpoint, onSuccess])
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!checkpoint) return
|
|
const promise = api.inspections.deleteCheckpointMedia(String(checkpoint.id))
|
|
toast.promise(promise, {
|
|
loading: "Removing attachment...",
|
|
success: "Attachment removed",
|
|
error: "Failed to remove attachment",
|
|
})
|
|
await promise
|
|
onSuccess()
|
|
}, [api, checkpoint, onSuccess])
|
|
|
|
const hasFile = !!checkpoint?.file
|
|
const FileIcon = checkpoint?.file ? getFileIcon(checkpoint.file) : File
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Attachments — {checkpoint?.name}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-4">
|
|
{/* Current attachment */}
|
|
{hasFile ? (
|
|
<div className="rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0 ">
|
|
{isImageUrl(checkpoint.file!) ? (
|
|
<img
|
|
src={checkpoint.file!}
|
|
alt="Checkpoint attachment"
|
|
className="size-12 rounded-md object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-muted">
|
|
<FileIcon className="size-5 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex flex-col gap-0.5 max-w-48">
|
|
<a
|
|
href={checkpoint.file!}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="truncate text-sm font-medium text-primary hover:underline"
|
|
>
|
|
{getFileName(checkpoint.file!)}
|
|
</a>
|
|
<span className="text-xs text-muted-foreground">
|
|
Current attachment
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-destructive hover:text-destructive"
|
|
onClick={handleDelete}
|
|
title="Remove attachment"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed p-6 text-muted-foreground">
|
|
<Paperclip className="size-8" />
|
|
<span className="text-sm">No attachments yet</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload area */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,audio/*,video/*"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleUpload(file)
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={isUploading}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<FileUp className="size-4" />
|
|
{isUploading
|
|
? "Uploading..."
|
|
: hasFile
|
|
? "Replace Attachment"
|
|
: "Add Attachment"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Main Page ──
|
|
|
|
export default function InspectionCheckpointsPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id: inspectionId } = use(params)
|
|
const api = useAuthApi()
|
|
const queryClient = useQueryClient()
|
|
|
|
const [formOpen, setFormOpen] = useState(false)
|
|
const [editingCheckpoint, setEditingCheckpoint] = useState<CheckpointItem | null>(null)
|
|
const [attachmentsCheckpoint, setAttachmentsCheckpoint] = useState<CheckpointItem | null>(null)
|
|
|
|
const queryKey = [INSPECTION_ROUTES.CHECKPOINTS, inspectionId]
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey,
|
|
queryFn: () => api.inspections.listCheckpoints({ inspection_id: inspectionId } as never),
|
|
})
|
|
|
|
const invalidate = () => {
|
|
queryClient.invalidateQueries({ queryKey })
|
|
}
|
|
|
|
// ── Status change mutation ──
|
|
const changeStatusMutation = useMutation({
|
|
mutationFn: ({ checkpointId, status }: { checkpointId: number; status: string }) =>
|
|
api.inspections.changeCheckpointStatus({
|
|
inspection_id: Number(inspectionId),
|
|
inspection_check_point_id: String(checkpointId),
|
|
name: "", // required by API type but server uses checkpoint id from context
|
|
record_type: "record_conditions",
|
|
status,
|
|
} as never),
|
|
onSuccess: () => invalidate(),
|
|
})
|
|
|
|
// ── Delete checkpoint mutation ──
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (checkpointId: string) => api.inspections.destroyCheckpoint(checkpointId),
|
|
onSuccess: () => {
|
|
toast.success("Checkpoint deleted")
|
|
invalidate()
|
|
},
|
|
onError: () => toast.error("Failed to delete checkpoint"),
|
|
})
|
|
|
|
const handleEdit = (checkpoint: CheckpointItem) => {
|
|
setEditingCheckpoint(checkpoint)
|
|
setFormOpen(true)
|
|
}
|
|
|
|
const handleAdd = () => {
|
|
setEditingCheckpoint(null)
|
|
setFormOpen(true)
|
|
}
|
|
|
|
const handleStatusChange = (checkpointId: number, status: string) => {
|
|
const promise = changeStatusMutation.mutateAsync({ checkpointId, status, })
|
|
toast.promise(promise, {
|
|
loading: "Updating status...",
|
|
success: `Status changed to ${formatStatus(status)}`,
|
|
error: "Failed to update status",
|
|
})
|
|
}
|
|
|
|
const checkpoints = (data as any)?.data ?? []
|
|
const meta = (data as any)?.meta
|
|
|
|
const pagination = {
|
|
page: meta?.current_page ?? 1,
|
|
pageSize: meta?.per_page ?? 15,
|
|
pageCount: meta?.last_page ?? 1,
|
|
total: meta?.total ?? 0,
|
|
}
|
|
|
|
const columns = [
|
|
{
|
|
accessorKey: "name",
|
|
header: ({ column }: any) => <ColumnHeader column={column} title="Name" />,
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="font-medium">{item.name}</span>
|
|
{item.description && (
|
|
<span className="text-xs text-muted-foreground">{item.description}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "record_type",
|
|
header: ({ column }: any) => <ColumnHeader column={column} title="Record Type" />,
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
const rt = RECORD_TYPES.find((r) => r.value === item.record_type)
|
|
return rt?.label ?? item.record_type ?? "—"
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: ({ column }: any) => <ColumnHeader column={column} title="Status" />,
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
const config = getStatusConfig(item.status)
|
|
return (
|
|
<Badge className={config.color}>
|
|
<config.icon className="mr-1 size-3" />
|
|
{formatStatus(item.status)}
|
|
</Badge>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "condition_rate",
|
|
header: ({ column }: any) => <ColumnHeader column={column} title="Condition" />,
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
return item.condition_rate != null ? `${item.condition_rate}/10` : "—"
|
|
},
|
|
},
|
|
{
|
|
id: "attachments",
|
|
header: () => <span className="text-xs">Attachments</span>,
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
onClick={() => setAttachmentsCheckpoint(item)}
|
|
>
|
|
<Paperclip className="size-3.5" />
|
|
{item.file ? (
|
|
<Badge variant="secondary" className="px-1.5 py-0 text-xs">1</Badge>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">0</span>
|
|
)}
|
|
</Button>
|
|
)
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
id: "actions",
|
|
cell: ({ row }: any) => {
|
|
const item = row.original as CheckpointItem
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="size-8">
|
|
<Ellipsis className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{CHECKPOINT_STATUSES.map((s) => (
|
|
<DropdownMenuItem
|
|
key={s.value}
|
|
onClick={() => handleStatusChange(item.id, s.value)}
|
|
disabled={item.status === s.value}
|
|
>
|
|
<s.icon className="size-4" />
|
|
{s.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => handleEdit(item)}>
|
|
<Pencil className="size-4" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => deleteMutation.mutate(String(item.id))}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
},
|
|
},
|
|
]
|
|
|
|
return (
|
|
<DashboardPage header={null}>
|
|
<div className="flex items-center justify-between p-4">
|
|
<h3 className="text-lg font-semibold">Checkpoints</h3>
|
|
<Button onClick={handleAdd} size="sm">
|
|
<Plus className="size-4" />
|
|
Add Checkpoint
|
|
</Button>
|
|
</div>
|
|
<DataTable
|
|
columns={columns}
|
|
data={checkpoints}
|
|
pagination={pagination}
|
|
sorting={[]}
|
|
onChange={() => {}}
|
|
isLoading={isLoading}
|
|
/>
|
|
<CheckpointFormDialog
|
|
open={formOpen}
|
|
onOpenChange={(open) => {
|
|
setFormOpen(open)
|
|
if (!open) setEditingCheckpoint(null)
|
|
}}
|
|
inspectionId={inspectionId}
|
|
checkpoint={editingCheckpoint}
|
|
onSuccess={invalidate}
|
|
/>
|
|
<CheckpointAttachmentsDialog
|
|
open={!!attachmentsCheckpoint}
|
|
onOpenChange={(open) => {
|
|
if (!open) setAttachmentsCheckpoint(null)
|
|
}}
|
|
checkpoint={attachmentsCheckpoint}
|
|
onSuccess={invalidate}
|
|
/>
|
|
</DashboardPage>
|
|
)
|
|
}
|