updates
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
"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 { SHOP_RECOMMENDATION_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 ShopRecommendation = {
|
||||
id: number
|
||||
description: string
|
||||
}
|
||||
|
||||
type ShopRecommendationsPage = {
|
||||
data: ShopRecommendation[]
|
||||
meta: {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
type RhfShopRecommendationsFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function extractPage(response: unknown): ShopRecommendationsPage {
|
||||
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 ShopRecommendationsSheet({
|
||||
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 = [SHOP_RECOMMENDATION_ROUTES.INDEX, { page, search }]
|
||||
|
||||
const { data, isLoading } = useQuery<ShopRecommendationsPage>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.shopRecommendations.list({
|
||||
page,
|
||||
...(search ? { search } : {}),
|
||||
})
|
||||
return extractPage(res)
|
||||
},
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const recommendations = data?.data ?? []
|
||||
const meta = data?.meta
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: [SHOP_RECOMMENDATION_ROUTES.INDEX] })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (description: string) =>
|
||||
api.shopRecommendations.create({ description }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setCreating(false)
|
||||
setNewDescription("")
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, description }: { id: number; description: string }) =>
|
||||
api.shopRecommendations.update(String(id), { description }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setEditingId(null)
|
||||
setEditingText("")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api.shopRecommendations.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(recommendation: ShopRecommendation) {
|
||||
setEditingId(recommendation.id)
|
||||
setEditingText(recommendation.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">Shop Recommendations</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 shop recommendation..."
|
||||
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>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
No shop recommendations found.
|
||||
</div>
|
||||
) : (
|
||||
recommendations.map((recommendation) => {
|
||||
const isSelected = selected.includes(recommendation.description)
|
||||
const isEditing = editingId === recommendation.id
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div key={recommendation.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(recommendation.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(recommendation.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={recommendation.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(recommendation.description)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onToggle(recommendation.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">{recommendation.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(recommendation)
|
||||
}}
|
||||
>
|
||||
<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(recommendation.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 RhfShopRecommendationsField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Shop Recommendations",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Enter recommendation...",
|
||||
}: RhfShopRecommendationsFieldProps<TValues, TName>) {
|
||||
const { control } = useFormContext<TValues>()
|
||||
const { field, fieldState: { error } } = useController({ name, control, disabled })
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const recommendations: string[] = Array.isArray(field.value) ? field.value : []
|
||||
|
||||
function updateAt(index: number, value: string) {
|
||||
const next = [...recommendations]
|
||||
next[index] = value
|
||||
field.onChange(next)
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
field.onChange([...recommendations, ""])
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
field.onChange(recommendations.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function toggleRecommendation(description: string) {
|
||||
const exists = recommendations.includes(description)
|
||||
if (exists) {
|
||||
field.onChange(recommendations.filter((r) => r !== description))
|
||||
} else {
|
||||
field.onChange([...recommendations, 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">
|
||||
{recommendations.map((recommendation, index) => (
|
||||
<div key={index} className="flex items-center gap-2 px-3 py-2 bg-background">
|
||||
<input
|
||||
type="text"
|
||||
value={recommendation}
|
||||
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 recommendation"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{recommendations.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground italic">
|
||||
No recommendations 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 Shop Recommendations"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Shop Recommendations</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <FieldError>{error.message}</FieldError>}
|
||||
{description && !error && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
<ShopRecommendationsSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
selected={recommendations}
|
||||
onToggle={toggleRecommendation}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user