2026-04-06 02:32:47 +03:00

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>
)
}