Feat: Add searchable customer combobox to ReservationForm

This commit is contained in:
2025-12-03 22:27:48 +03:00
parent e4adb85498
commit a84985b40f
5 changed files with 276 additions and 15 deletions

17
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@supabase/supabase-js": "^2.86.0", "@supabase/supabase-js": "^2.86.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next": "16.0.7", "next": "16.0.7",
@@ -4474,6 +4475,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -24,6 +24,7 @@
"@supabase/supabase-js": "^2.86.0", "@supabase/supabase-js": "^2.86.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"next": "16.0.7", "next": "16.0.7",

View File

@@ -7,7 +7,7 @@ export default async function NewReservationPage() {
// Fetch necessary data for the form // Fetch necessary data for the form
const { data: halls } = await supabase.from('halls').select('id, name') const { data: halls } = await supabase.from('halls').select('id, name')
const { data: customers } = await supabase.from('customers').select('id, full_name').order('created_at', { ascending: false }).limit(50) const { data: customers } = await supabase.from('customers').select('id, full_name, phone').order('created_at', { ascending: false }).limit(100)
const { data: packages } = await supabase.from('packages').select('id, name, price').eq('is_active', true) const { data: packages } = await supabase.from('packages').select('id, name, price').eq('is_active', true)
return ( return (

View File

@@ -18,6 +18,21 @@ import { Textarea } from "@/components/ui/textarea"
import { createReservation } from "./actions" import { createReservation } from "./actions"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const formSchema = z.object({ const formSchema = z.object({
hall_id: z.string().min(1, "Salon seçmelisiniz."), hall_id: z.string().min(1, "Salon seçmelisiniz."),
@@ -31,13 +46,14 @@ const formSchema = z.object({
interface ReservationFormProps { interface ReservationFormProps {
halls: { id: string, name: string }[] halls: { id: string, name: string }[]
customers: { id: string, full_name: string }[] customers: { id: string, full_name: string, phone?: string | null }[]
packages: { id: string, name: string, price: number }[] packages: { id: string, name: string, price: number }[]
} }
export function ReservationForm({ halls, customers, packages }: ReservationFormProps) { export function ReservationForm({ halls, customers, packages }: ReservationFormProps) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [openCustomer, setOpenCustomer] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -123,20 +139,63 @@ export function ReservationForm({ halls, customers, packages }: ReservationFormP
control={form.control} control={form.control}
name="customer_id" name="customer_id"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-col">
<FormLabel>Müşteri</FormLabel> <FormLabel>Müşteri</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Popover open={openCustomer} onOpenChange={setOpenCustomer}>
<FormControl> <PopoverTrigger asChild>
<SelectTrigger> <FormControl>
<SelectValue placeholder="Müşteri Seçin" /> <Button
</SelectTrigger> variant="outline"
</FormControl> role="combobox"
<SelectContent> aria-expanded={openCustomer}
{customers.map(c => ( className={cn(
<SelectItem key={c.id} value={c.id}>{c.full_name}</SelectItem> "w-full justify-between",
))} !field.value && "text-muted-foreground"
</SelectContent> )}
</Select> >
{field.value
? customers.find((customer) => customer.id === field.value)?.full_name
: "Müşteri Seçin"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Müşteri ara (İsim veya Telefon)..." />
<CommandList>
<CommandEmpty>Müşteri bulunamadı.</CommandEmpty>
<CommandGroup>
{customers.map((customer) => (
<CommandItem
value={`${customer.full_name} ${customer.phone || ''}`}
key={customer.id}
onSelect={() => {
form.setValue("customer_id", customer.id)
setOpenCustomer(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
customer.id === field.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{customer.full_name}</span>
{customer.phone && (
<span className="text-xs text-muted-foreground">{customer.phone}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}