garage-erp/apps/dashboard/modules/labels/rhf-label-picker-field.tsx
2026-04-07 06:32:40 +03:00

338 lines
13 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 } from "@tanstack/react-query"
import { Check, PlusIcon, X } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { LABEL_ROUTES } from "@garage/api"
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Field,
FieldLabel,
FieldError,
FieldDescription,
} from "@/shared/components/ui/field"
import { cn } from "@/shared/lib/utils"
// ── Types ──
export type LabelItem = {
id: number
title: string
color_code: string
}
type RhfLabelPickerFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Helpers ──
function extractLabels(response: unknown): LabelItem[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
// ── Component ──
export function RhfLabelPickerField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label,
description,
required,
disabled,
placeholder = "Select labels...",
}: RhfLabelPickerFieldProps<TValues, TName>) {
const api = useAuthApi()
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const { field, fieldState: { error } } = useController({ name, control, disabled })
const [open, setOpen] = useState(false)
const [search, setSearch] = useState("")
const [creating, setCreating] = useState(false)
const [newTitle, setNewTitle] = useState("")
const [newColor, setNewColor] = useState("#6366f1")
const [isSubmitting, setIsSubmitting] = useState(false)
const { data: allLabels = [] } = useQuery<LabelItem[]>({
queryKey: [LABEL_ROUTES.INDEX],
queryFn: async () => {
const res = await api.labels.list()
return extractLabels(res)
},
staleTime: 5 * 60 * 1000,
})
const selected: LabelItem[] = field.value ?? []
const filtered = search
? allLabels.filter((l) =>
l.title.toLowerCase().includes(search.toLowerCase()),
)
: allLabels
function toggle(lbl: LabelItem) {
const isSelected = selected.some((s) => s.id === lbl.id)
if (isSelected) {
field.onChange(selected.filter((s) => s.id !== lbl.id))
} else {
field.onChange([...selected, lbl])
}
}
function remove(id: number, e: React.MouseEvent) {
e.stopPropagation()
field.onChange(selected.filter((s) => s.id !== id))
}
async function handleCreate() {
if (!newTitle.trim()) return
setIsSubmitting(true)
try {
const res = await api.labels.create({
title: newTitle.trim(),
color_code: newColor,
}) as any
const created = res?.data ?? res
queryClient.invalidateQueries({ queryKey: [LABEL_ROUTES.INDEX] })
if (created?.id) {
field.onChange([
...selected,
{
id: created.id,
title: created.title ?? newTitle.trim(),
color_code: created.color_code ?? newColor,
},
])
}
setNewTitle("")
setNewColor("#6366f1")
setCreating(false)
} catch {
// silent toast handled upstream if desired
} finally {
setIsSubmitting(false)
}
}
return (
<Field data-invalid={!!error || undefined}>
{label && (
<FieldLabel>
{label}
{required && (
<span className="text-destructive ms-0.5">*</span>
)}
</FieldLabel>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
aria-expanded={open}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setOpen((v) => !v)
}
}}
className={cn(
"flex min-h-9 w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-50",
error && "border-destructive",
)}
>
{selected.length === 0 && (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
{selected.map((s) => (
<span
key={s.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: s.color_code + "28",
borderColor: s.color_code + "80",
color: s.color_code,
}}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: s.color_code }}
/>
{s.title}
<button
type="button"
className="ml-0.5 rounded-full opacity-70 hover:opacity-100"
onClick={(e) => remove(s.id, e)}
tabIndex={-1}
aria-label={`Remove ${s.title}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
</PopoverTrigger>
<PopoverContent
className="flex w-64 flex-col overflow-hidden p-0"
align="start"
style={{
maxHeight:
"var(--radix-popover-content-available-height, 320px)",
}}
>
{/* Search */}
<div className="shrink-0 p-2">
<Input
placeholder="Search labels..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
</div>
{/* List */}
<ScrollArea className="min-h-0 flex-1">
<div className="px-1 pb-1">
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
No labels found
</p>
)}
{filtered.map((lbl) => {
const isSelected = selected.some(
(s) => s.id === lbl.id,
)
return (
<button
key={lbl.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => toggle(lbl)}
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor: lbl.color_code,
}}
/>
<span className="flex-1 text-start">
{lbl.title}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
)
})}
</div>
</ScrollArea>
{/* Footer: create */}
<div className="shrink-0 border-t p-2">
{creating ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Label name"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="h-8"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCreate()
}
if (e.key === "Escape") setCreating(false)
}}
autoFocus
/>
<div className="flex items-center gap-2">
<input
type="color"
value={newColor}
onChange={(e) =>
setNewColor(e.target.value)
}
className="h-8 w-8 cursor-pointer rounded border p-0.5"
title="Pick a color"
/>
<span className="flex-1 text-xs text-muted-foreground">
Color
</span>
<Button
type="button"
size="sm"
className="h-7 text-xs"
onClick={handleCreate}
disabled={
isSubmitting || !newTitle.trim()
}
>
{isSubmitting ? "..." : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => setCreating(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-start gap-1.5 text-xs"
onClick={() => setCreating(true)}
>
<PlusIcon className="h-3.5 w-3.5" />
Create label
</Button>
)}
</div>
</PopoverContent>
</Popover>
{description && (
<FieldDescription>{description}</FieldDescription>
)}
{error && <FieldError>{error.message}</FieldError>}
</Field>
)
}