549 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|