567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import {
|
||
useFormContext,
|
||
useController,
|
||
type FieldValues,
|
||
type FieldPath,
|
||
} from "react-hook-form"
|
||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||
import {
|
||
Check,
|
||
ChevronFirst,
|
||
ChevronLast,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Edit2,
|
||
MoreHorizontal,
|
||
Plus,
|
||
RefreshCcw,
|
||
Search,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react"
|
||
|
||
import { useAuthApi } from "@/shared/useApi"
|
||
import { QUICK_REMARK_ROUTES } from "@garage/api"
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/shared/components/ui/sheet"
|
||
import { Button } from "@/shared/components/ui/button"
|
||
import { Input } from "@/shared/components/ui/input"
|
||
import {
|
||
Field,
|
||
FieldLabel,
|
||
FieldError,
|
||
} from "@/shared/components/ui/field"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/shared/components/ui/dropdown-menu"
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipTrigger,
|
||
} from "@/shared/components/ui/tooltip"
|
||
import { cn } from "@/shared/lib/utils"
|
||
|
||
// ── Types ──
|
||
|
||
type QuickRemark = {
|
||
id: number
|
||
description: string
|
||
}
|
||
|
||
type QuickRemarksPage = {
|
||
data: QuickRemark[]
|
||
meta: {
|
||
current_page: number
|
||
last_page: number
|
||
per_page: number
|
||
total: number
|
||
}
|
||
}
|
||
|
||
type RhfCustomerRemarksFieldProps<
|
||
TValues extends FieldValues,
|
||
TName extends FieldPath<TValues>,
|
||
> = {
|
||
name: TName
|
||
label?: string
|
||
description?: string
|
||
required?: boolean
|
||
disabled?: boolean
|
||
placeholder?: string
|
||
}
|
||
|
||
// ── Helpers ──
|
||
|
||
function extractPage(response: unknown): QuickRemarksPage {
|
||
const r = response as any
|
||
return {
|
||
data: Array.isArray(r?.data?.data)
|
||
? r.data.data
|
||
: Array.isArray(r?.data)
|
||
? r.data
|
||
: [],
|
||
meta: r?.data?.meta ?? r?.meta ?? {
|
||
current_page: 1,
|
||
last_page: 1,
|
||
per_page: 15,
|
||
total: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── QuickRemarksSheet ──
|
||
|
||
function QuickRemarksSheet({
|
||
open,
|
||
onOpenChange,
|
||
selected,
|
||
onToggle,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (v: boolean) => void
|
||
selected: string[]
|
||
onToggle: (description: string) => void
|
||
}) {
|
||
const api = useAuthApi()
|
||
const queryClient = useQueryClient()
|
||
|
||
const [page, setPage] = useState(1)
|
||
const [search, setSearch] = useState("")
|
||
const [creating, setCreating] = useState(false)
|
||
const [newDescription, setNewDescription] = useState("")
|
||
const [editingId, setEditingId] = useState<number | null>(null)
|
||
const [editingText, setEditingText] = useState("")
|
||
|
||
const queryKey = [QUICK_REMARK_ROUTES.INDEX, { page, search }]
|
||
|
||
const { data, isLoading } = useQuery<QuickRemarksPage>({
|
||
queryKey,
|
||
queryFn: async () => {
|
||
const res = await api.quickRemarks.list({
|
||
page,
|
||
...(search ? { search } : {}),
|
||
})
|
||
return extractPage(res)
|
||
},
|
||
enabled: open,
|
||
staleTime: 30_000,
|
||
})
|
||
|
||
const remarks = data?.data ?? []
|
||
const meta = data?.meta
|
||
|
||
const invalidate = () =>
|
||
queryClient.invalidateQueries({ queryKey: [QUICK_REMARK_ROUTES.INDEX] })
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (description: string) =>
|
||
api.quickRemarks.create({ description }),
|
||
onSuccess: () => {
|
||
invalidate()
|
||
setCreating(false)
|
||
setNewDescription("")
|
||
},
|
||
})
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, description }: { id: number; description: string }) =>
|
||
api.quickRemarks.update(String(id), { description }),
|
||
onSuccess: () => {
|
||
invalidate()
|
||
setEditingId(null)
|
||
setEditingText("")
|
||
},
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: number) =>
|
||
api.quickRemarks.destroy(String(id)),
|
||
onSuccess: () => invalidate(),
|
||
})
|
||
|
||
function handleCreate() {
|
||
const text = newDescription.trim()
|
||
if (!text) return
|
||
createMutation.mutate(text)
|
||
}
|
||
|
||
function handleUpdate(id: number) {
|
||
const text = editingText.trim()
|
||
if (!text) return
|
||
updateMutation.mutate({ id, description: text })
|
||
}
|
||
|
||
function startEdit(remark: QuickRemark) {
|
||
setEditingId(remark.id)
|
||
setEditingText(remark.description)
|
||
}
|
||
|
||
const totalPages = meta?.last_page ?? 1
|
||
const from = meta ? (meta.current_page - 1) * meta.per_page + 1 : 0
|
||
const to = meta ? Math.min(meta.current_page * meta.per_page, meta.total) : 0
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent side="right" className="flex w-105 flex-col gap-0 p-0 sm:w-120">
|
||
{/* Header */}
|
||
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||
<SheetTitle className="text-base font-semibold">Quick Remarks</SheetTitle>
|
||
|
||
</SheetHeader>
|
||
|
||
{/* Search */}
|
||
<div className="border-b px-3 py-2 flex gap-1 items-center">
|
||
<div className="relative grow">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Search..."
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value)
|
||
setPage(1)
|
||
}}
|
||
className="pl-9 h-9"
|
||
/>
|
||
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={() => {
|
||
setCreating(true)
|
||
setEditingId(null)
|
||
}}
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
Add
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Inline create form */}
|
||
{creating && (
|
||
<div className="border-b px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
autoFocus
|
||
placeholder="New quick remark..."
|
||
value={newDescription}
|
||
onChange={(e) => setNewDescription(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); handleCreate() }
|
||
if (e.key === "Escape") { setCreating(false); setNewDescription("") }
|
||
}}
|
||
className="h-8 text-sm"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={createMutation.isPending || !newDescription.trim()}
|
||
onClick={handleCreate}
|
||
>
|
||
Save
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => { setCreating(false); setNewDescription("") }}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* List */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||
Loading...
|
||
</div>
|
||
) : remarks.length === 0 ? (
|
||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||
No quick remarks found.
|
||
</div>
|
||
) : (
|
||
remarks.map((remark) => {
|
||
const isSelected = selected.includes(remark.description)
|
||
const isEditing = editingId === remark.id
|
||
|
||
if (isEditing) {
|
||
return (
|
||
<div key={remark.id} className="flex items-center gap-2 border-b px-4 py-2">
|
||
<Input
|
||
autoFocus
|
||
value={editingText}
|
||
onChange={(e) => setEditingText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); handleUpdate(remark.id) }
|
||
if (e.key === "Escape") { setEditingId(null); setEditingText("") }
|
||
}}
|
||
className="h-8 flex-1 text-sm"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={updateMutation.isPending || !editingText.trim()}
|
||
onClick={() => handleUpdate(remark.id)}
|
||
>
|
||
Save
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => { setEditingId(null); setEditingText("") }}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={remark.id}
|
||
className={cn(
|
||
"flex cursor-pointer items-center justify-between border-b px-4 py-3 transition-colors hover:bg-muted/50",
|
||
isSelected && "bg-primary/5",
|
||
)}
|
||
onClick={() => onToggle(remark.description)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault()
|
||
onToggle(remark.description)
|
||
}
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<div className={cn(
|
||
"flex h-4 w-4 shrink-0 items-center justify-center",
|
||
)}>
|
||
{isSelected && (
|
||
<Check className="h-4 w-4 text-primary" />
|
||
)}
|
||
</div>
|
||
<span className="truncate text-sm">{remark.description}</span>
|
||
</div>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 shrink-0"
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
<span className="sr-only">Actions</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
startEdit(remark)
|
||
}}
|
||
>
|
||
<Edit2 className="mr-2 h-3.5 w-3.5" />
|
||
Edit
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="text-destructive focus:text-destructive"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
deleteMutation.mutate(remark.id)
|
||
}}
|
||
>
|
||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||
Delete
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{meta && meta.total > 0 && (
|
||
<div className="flex items-center justify-between border-t px-4 py-2 text-sm text-muted-foreground">
|
||
<span>
|
||
{from}–{to} of {meta.total}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage(1)}
|
||
>
|
||
<ChevronFirst className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage((p) => p - 1)}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage(totalPages)}
|
||
>
|
||
<ChevronLast className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ── Main Component ──
|
||
|
||
export function RhfCustomerRemarksField<
|
||
TValues extends FieldValues,
|
||
TName extends FieldPath<TValues>,
|
||
>({
|
||
name,
|
||
label = "Customer Remark",
|
||
description,
|
||
required,
|
||
disabled,
|
||
placeholder = "Enter remark...",
|
||
}: RhfCustomerRemarksFieldProps<TValues, TName>) {
|
||
const { control } = useFormContext<TValues>()
|
||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||
|
||
const [sheetOpen, setSheetOpen] = useState(false)
|
||
|
||
const remarks: string[] = Array.isArray(field.value) ? field.value : []
|
||
|
||
function updateAt(index: number, value: string) {
|
||
const next = [...remarks]
|
||
next[index] = value
|
||
field.onChange(next)
|
||
}
|
||
|
||
function addLine() {
|
||
field.onChange([...remarks, ""])
|
||
}
|
||
|
||
function removeLine(index: number) {
|
||
field.onChange(remarks.filter((_, i) => i !== index))
|
||
}
|
||
|
||
function toggleQuickRemark(description: string) {
|
||
const exists = remarks.includes(description)
|
||
if (exists) {
|
||
field.onChange(remarks.filter((r) => r !== description))
|
||
} else {
|
||
field.onChange([...remarks, description])
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Field data-invalid={!!error || undefined}>
|
||
{label && (
|
||
<FieldLabel>
|
||
{label}
|
||
{required && (
|
||
<span className="text-destructive ms-0.5">*</span>
|
||
)}
|
||
</FieldLabel>
|
||
)}
|
||
|
||
{/* Repeater rows */}
|
||
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
|
||
{remarks.map((remark, index) => (
|
||
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
|
||
<input
|
||
type="text"
|
||
value={remark}
|
||
onChange={(e) => updateAt(index, e.target.value)}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => removeLine(index)}
|
||
className="shrink-0 rounded-full p-0.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40"
|
||
aria-label="Remove remark"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{/* Empty state */}
|
||
{remarks.length === 0 && (
|
||
<div className="px-3 py-2 text-sm text-muted-foreground italic">
|
||
No remarks added yet.
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer row */}
|
||
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={disabled}
|
||
onClick={addLine}
|
||
className="h-7 gap-1.5 text-xs text-primary hover:text-primary"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
New Line
|
||
</Button>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="default"
|
||
size="icon"
|
||
disabled={disabled}
|
||
onClick={() => setSheetOpen(true)}
|
||
className="h-8 w-8 rounded-full"
|
||
aria-label="Pick from Quick Remarks"
|
||
>
|
||
<RefreshCcw className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="left">Quick Remarks</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <FieldError>{error.message}</FieldError>}
|
||
{description && !error && (
|
||
<p className="text-xs text-muted-foreground">{description}</p>
|
||
)}
|
||
|
||
<QuickRemarksSheet
|
||
open={sheetOpen}
|
||
onOpenChange={setSheetOpen}
|
||
selected={remarks}
|
||
onToggle={toggleQuickRemark}
|
||
/>
|
||
</Field>
|
||
)
|
||
}
|