Add CommissionTypeForm

This commit is contained in:
2025-07-05 23:31:32 +02:00
parent babc1d95ba
commit 648ecda8d4
21 changed files with 1485 additions and 76 deletions

View File

@ -0,0 +1,64 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma";
type CommissionTypeWithItems = CommissionType & {
options: (CommissionTypeOption & {
option: CommissionOption | null
})[]
extras: (CommissionTypeExtra & {
extra: CommissionExtra | null
})[]
}
export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{types.map(type => (
<Card key={type.id}>
<CardHeader>
<CardTitle className="text-xl truncate">{type.name}</CardTitle>
<CardDescription>{type.description}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{type.options.map((opt) => (
<li key={opt.id}>
{opt.option?.name}:{" "}
{opt.price !== null
? `${opt.price}`
: opt.pricePercent
? `+${opt.pricePercent}%`
: opt.priceRange
? `${opt.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{type.extras.map((ext) => (
<li key={ext.id}>
{ext.extra?.name}:{" "}
{ext.price !== null
? `${ext.price}`
: ext.pricePercent
? `+${ext.pricePercent}%`
: ext.priceRange
? `${ext.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client"
import { createCommissionType } from "@/actions/items/commissions/types/newType";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { CommissionExtra, CommissionOption } from "@/generated/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField";
type Props = {
options: CommissionOption[],
extras: CommissionExtra[],
}
export default function NewTypeForm({ options, extras }: Props) {
const router = useRouter();
const form = useForm<z.infer<typeof commissionTypeSchema>>({
resolver: zodResolver(commissionTypeSchema),
defaultValues: {
name: "",
description: "",
options: [],
extras: [],
},
})
async function onSubmit(values: z.infer<typeof commissionTypeSchema>) {
try {
const created = await createCommissionType(values)
console.log("CommissionType created:", created)
router.push("/items/commissions/types")
} catch (err) {
console.error(err)
toast("Failed to create commission type.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>The name of the commission type.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<CommissionOptionField options={options} />
<CommissionExtraField extras={extras} />
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { Check, ChevronsUpDown, PlusCircle } from "lucide-react"
import { useState } from "react"
type Option = {
label: string
value: string
}
type Props = {
options: Option[]
selected: string | undefined
onSelect: (value: string) => void
onCreateNew: (name: string) => void | Promise<void>
placeholder?: string
disabled?: boolean
}
export function ComboboxCreateable({
options,
selected,
onSelect,
onCreateNew,
placeholder = "Select or create…",
disabled = false,
}: Props) {
const [open, setOpen] = useState(false)
const [input, setInput] = useState("")
const selectedOption = options.find((o) => o.value === selected)
const filteredOptions = input
? options.filter((opt) =>
opt.label.toLowerCase().includes(input.toLowerCase())
)
: options
const showCreate = input && !options.some((o) => o.label.toLowerCase() === input.toLowerCase())
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
disabled={disabled}
>
{selectedOption?.label || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput
placeholder={placeholder}
value={input}
onValueChange={setInput}
/>
<CommandEmpty>No results</CommandEmpty>
<CommandGroup>
{filteredOptions.map((opt) => (
<CommandItem
key={opt.value}
onSelect={() => {
onSelect(opt.value)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selected === opt.value ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
{showCreate && (
<CommandItem
onSelect={async () => {
await onCreateNew(input)
setOpen(false)
}}
className="text-primary"
>
<PlusCircle className="mr-2 h-4 w-4" />
Create {input}
</CommandItem>
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,159 @@
"use client"
import { createCommissionExtra } from "@/actions/items/commissions/types/newType"
import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range"
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { CommissionExtra } from "@/generated/prisma"
import { useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable"
type Props = {
extras: CommissionExtra[]
}
export function CommissionExtraField({ extras: initialExtras }: Props) {
const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({
control,
name: "extras",
})
const [extras, setExtras] = useState(initialExtras)
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Extras</h3>
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-5 gap-4 items-end">
{/* Extra Picker (combobox with create) */}
<FormField
control={control}
name={`extras.${index}.extraId`}
render={({ field: extraField }) => (
<FormItem>
<FormLabel>Extra</FormLabel>
<FormControl>
<ComboboxCreateable
options={extras.map((e) => ({
label: e.name,
value: e.id,
}))}
selected={extraField.value}
onSelect={extraField.onChange}
onCreateNew={async (name) => {
const newExtra = await createCommissionExtra({ name })
setExtras((prev) => [...prev, newExtra])
extraField.onChange(newExtra.id)
}}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price */}
<FormField
control={control}
name={`extras.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel>Price ()</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price Percent */}
<FormField
control={control}
name={`extras.${index}.pricePercent`}
render={({ field }) => (
<FormItem>
<FormLabel>+ %</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
}
/>
</FormControl>
</FormItem>
)}
/>
{/* Range Slider */}
<FormField
control={control}
name={`extras.${index}.priceRange`}
render={({ field }) => {
const [start, end] =
typeof field.value === "string" && field.value.includes("")
? field.value.split("").map(Number)
: [0, 0]
return (
<FormItem>
<FormLabel>Range</FormLabel>
<FormControl>
<DualRangeSlider
value={[start, end]}
onChange={([min, max]) =>
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
}
/>
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
onClick={() =>
append({
extraId: "",
price: undefined,
pricePercent: undefined,
priceRange: "00",
})
}
>
Add Extra
</Button>
</div>
)
}

View File

@ -0,0 +1,160 @@
"use client"
import { createCommissionOption } from "@/actions/items/commissions/types/newType"
import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range"
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { CommissionOption } from "@/generated/prisma"
import { useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable"
type Props = {
options: CommissionOption[]
}
export function CommissionOptionField({ options: initialOptions }: Props) {
const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({
control,
name: "options",
})
const [options, setOptions] = useState(initialOptions)
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Options</h3>
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-5 gap-4 items-end">
{/* Option Picker (combobox with create) */}
<FormField
control={control}
name={`options.${index}.optionId`}
render={({ field: optionField }) => (
<FormItem>
<FormLabel>Option</FormLabel>
<FormControl>
<ComboboxCreateable
options={options.map((o) => ({
label: o.name,
value: o.id,
}))}
selected={optionField.value}
onSelect={optionField.onChange}
onCreateNew={async (name) => {
const newOption = await createCommissionOption({ name })
setOptions((prev) => [...prev, newOption])
optionField.onChange(newOption.id)
}}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price */}
<FormField
control={control}
name={`options.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel>Price ()</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price Percent */}
<FormField
control={control}
name={`options.${index}.pricePercent`}
render={({ field }) => (
<FormItem>
<FormLabel>+ %</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price Range Slider */}
<FormField
control={control}
name={`options.${index}.priceRange`}
render={({ field }) => {
const [start, end] =
typeof field.value === "string" && field.value.includes("")
? field.value.split("").map(Number)
: [0, 0]
return (
<FormItem>
<FormLabel>Range</FormLabel>
<FormControl>
<DualRangeSlider
value={[start, end]}
onChange={([min, max]) =>
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
}
/>
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
{/* Add Button */}
<Button
type="button"
onClick={() =>
append({
optionId: "",
price: undefined,
pricePercent: undefined,
priceRange: "00",
})
}
>
Add Option
</Button>
</div>
)
}

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,
}

View File

@ -0,0 +1,39 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
type DualRangeProps = {
value: [number, number];
onChange: (val: [number, number]) => void;
min?: number;
max?: number;
step?: number;
};
export function DualRangeSlider({
value,
onChange,
min = 0,
max = 200,
step = 1,
}: DualRangeProps) {
return (
<div>
<div className="text-sm mb-1">Range: {value[0]}{value[1]}</div>
<SliderPrimitive.Root
className="relative flex w-full touch-none select-none items-center"
min={min}
max={max}
step={step}
value={value}
onValueChange={(val) => onChange([val[0], val[1]])}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full bg-white border border-primary shadow transition-colors focus:outline-none" />
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full bg-white border border-primary shadow transition-colors focus:outline-none" />
</SliderPrimitive.Root>
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }