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

549 lines
21 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_NOTE_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"
type QuickNote = {
id: number
description: string
}
type QuickNotesPage = {
data: QuickNote[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
type RhfQuickNotesFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
function extractPage(response: unknown): QuickNotesPage {
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,
},
}
}
function QuickNotesSheet({
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_NOTE_ROUTES.INDEX, { page, search }]
const { data, isLoading } = useQuery<QuickNotesPage>({
queryKey,
queryFn: async () => {
const res = await api.quickNotes.list({
page,
...(search ? { search } : {}),
})
return extractPage(res)
},
enabled: open,
staleTime: 30_000,
})
const notes = data?.data ?? []
const meta = data?.meta
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: [QUICK_NOTE_ROUTES.INDEX] })
const createMutation = useMutation({
mutationFn: (description: string) =>
api.quickNotes.create({ description }),
onSuccess: () => {
invalidate()
setCreating(false)
setNewDescription("")
},
})
const updateMutation = useMutation({
mutationFn: ({ id, description }: { id: number; description: string }) =>
api.quickNotes.update(String(id), { description }),
onSuccess: () => {
invalidate()
setEditingId(null)
setEditingText("")
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) =>
api.quickNotes.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(note: QuickNote) {
setEditingId(note.id)
setEditingText(note.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">
<SheetHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<SheetTitle className="text-base font-semibold">Quick Notes</SheetTitle>
</SheetHeader>
<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>
{creating && (
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="New quick note..."
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>
)}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : notes.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
No quick notes found.
</div>
) : (
notes.map((note) => {
const isSelected = selected.includes(note.description)
const isEditing = editingId === note.id
if (isEditing) {
return (
<div key={note.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(note.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(note.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={note.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(note.description)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggle(note.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">{note.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(note)
}}
>
<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(note.id)
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
)}
</div>
{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>
)
}
export function RhfQuickNotesField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Quick Notes",
description,
required,
disabled,
placeholder = "Enter note...",
}: RhfQuickNotesFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [sheetOpen, setSheetOpen] = useState(false)
const notes: string[] = Array.isArray(field.value) ? field.value : []
function updateAt(index: number, value: string) {
const next = [...notes]
next[index] = value
field.onChange(next)
}
function addLine() {
field.onChange([...notes, ""])
}
function removeLine(index: number) {
field.onChange(notes.filter((_, i) => i !== index))
}
function toggleQuickNote(description: string) {
const exists = notes.includes(description)
if (exists) {
field.onChange(notes.filter((r) => r !== description))
} else {
field.onChange([...notes, description])
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<div className="flex flex-col rounded-md border divide-y overflow-hidden">
{notes.map((note, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
<input
type="text"
value={note}
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 note"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{notes.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground italic">
No notes added yet.
</div>
)}
<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 Notes"
>
<RefreshCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Quick Notes</TooltipContent>
</Tooltip>
</div>
</div>
{error && <FieldError>{error.message}</FieldError>}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
<QuickNotesSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
selected={notes}
onToggle={toggleQuickNote}
/>
</Field>
)
}