garage-erp/apps/dashboard/modules/estimates/rhf-customer-remarks-field.tsx
2026-04-07 06:32:40 +03:00

567 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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