Add CommissionTypeForm
This commit is contained in:
64
src/components/items/commissions/types/ListTypes.tsx
Normal file
64
src/components/items/commissions/types/ListTypes.tsx
Normal 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>
|
||||
);
|
||||
}
|
89
src/components/items/commissions/types/NewTypeForm.tsx
Normal file
89
src/components/items/commissions/types/NewTypeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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: "0–0",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Extra
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -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: "0–0",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user