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