Add commission types

This commit is contained in:
2025-12-24 00:52:47 +01:00
parent 56142dbe73
commit ee454261cb
25 changed files with 1924 additions and 0 deletions

View File

@ -0,0 +1,19 @@
"use server"
import { prisma } from "@/lib/prisma"
export async function deleteCommissionType(typeId: string) {
await prisma.commissionTypeOption.deleteMany({
where: { typeId },
})
await prisma.commissionTypeExtra.deleteMany({
where: { typeId },
})
await prisma.commissionType.delete({
where: { id: typeId },
})
}

View File

@ -0,0 +1,81 @@
"use server"
import { prisma } from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissionType"
export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({
data: {
name: data.name,
description: "",
},
})
}
export async function createCommissionExtra(data: { name: string }) {
return await prisma.commissionExtra.create({
data: {
name: data.name,
description: "",
},
})
}
export async function createCommissionCustomInput(data: {
name: string
fieldId: string
}) {
return await prisma.commissionCustomInput.create({
data: {
name: data.name,
fieldId: data.fieldId,
},
})
}
export async function createCommissionType(formData: commissionTypeSchema) {
const parsed = commissionTypeSchema.safeParse(formData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const created = await prisma.commissionType.create({
data: {
name: data.name,
description: data.description,
options: {
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price,
pricePercent: opt.pricePercent,
priceRange: opt.priceRange,
sortIndex: index,
})) || [],
},
extras: {
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price,
pricePercent: ext.pricePercent,
priceRange: ext.priceRange,
sortIndex: index,
})) || [],
},
customInputs: {
create: data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } },
label: c.label,
inputType: c.inputType,
required: c.required,
sortIndex: index,
})) || [],
},
},
})
return created
}

View File

@ -0,0 +1,16 @@
"use server"
import { prisma } from "@/lib/prisma";
export async function updateCommissionTypeSortOrder(
ordered: { id: string; sortIndex: number }[]
) {
const updates = ordered.map(({ id, sortIndex }) =>
prisma.commissionType.update({
where: { id },
data: { sortIndex },
})
)
await Promise.all(updates)
}

View File

@ -0,0 +1,57 @@
"use server"
import { prisma } from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissionType"
import * as z from "zod/v4"
export async function updateCommissionType(
id: string,
rawData: z.infer<typeof commissionTypeSchema>
) {
const data = commissionTypeSchema.parse(rawData)
const updated = await prisma.commissionType.update({
where: { id },
data: {
name: data.name,
description: data.description,
options: {
deleteMany: {},
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price ?? null,
pricePercent: opt.pricePercent ?? null,
priceRange: opt.priceRange ?? null,
sortIndex: index,
})),
},
extras: {
deleteMany: {},
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price ?? null,
pricePercent: ext.pricePercent ?? null,
priceRange: ext.priceRange ?? null,
sortIndex: index,
})),
},
customInputs: {
deleteMany: {},
create: data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } },
label: c.label,
inputType: c.inputType,
required: c.required,
sortIndex: index,
})) || [],
},
},
include: {
options: true,
extras: true,
customInputs: true,
},
})
return updated
}

View File

@ -0,0 +1,5 @@
export default function CommissionPage() {
return (
<div>CommissionPage</div>
);
}

View File

@ -0,0 +1,38 @@
import EditTypeForm from "@/components/commissions/types/EditTypeForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionTypesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const commissionType = await prisma.commissionType.findUnique({
where: {
id,
},
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
},
})
const options = await prisma.commissionOption.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
const customInputs = await prisma.commissionCustomInput.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
if (!commissionType) {
return <div>Type not found</div>
}
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1>
</div>
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} allCustomInputs={customInputs} />
</div>
);
}

View File

@ -0,0 +1,24 @@
import NewTypeForm from "@/components/commissions/types/NewTypeForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionTypesNewPage() {
const options = await prisma.commissionOption.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
const customInputs = await prisma.commissionCustomInput.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Commission Type</h1>
</div>
<NewTypeForm options={options} extras={extras} customInputs={customInputs} />
</div>
);
}

View File

@ -0,0 +1,27 @@
import ListTypes from "@/components/commissions/types/ListTypes";
import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function CommissionTypesPage() {
const types = await prisma.commissionType.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Commission Types</h1>
<Link href="/commissions/types/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Type
</Link>
</div>
{types && types.length > 0 ? <ListTypes types={types} /> : <p className="text-muted-foreground italic">No types found.</p>}
</div>
);
}

View File

@ -0,0 +1,116 @@
"use client"
import { updateCommissionType } from "@/actions/commissions/types/updateType";
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 { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
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 { CommissionCustomInputField } from "./form/CommissionCustomInputField";
import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField";
type CommissionTypeWithConnections = CommissionType & {
options: (CommissionTypeOption & { option: CommissionOption })[]
extras: (CommissionTypeExtra & { extra: CommissionExtra })[]
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[]
}
type Props = {
type: CommissionTypeWithConnections
allOptions: CommissionOption[],
allExtras: CommissionExtra[],
allCustomInputs: CommissionCustomInput[]
}
export default function EditTypeForm({ type, allOptions, allExtras, allCustomInputs }: Props) {
const router = useRouter();
const form = useForm<z.infer<typeof commissionTypeSchema>>({
resolver: zodResolver(commissionTypeSchema),
defaultValues: {
name: type.name,
description: type.description ?? "",
options: type.options.map((o) => ({
optionId: o.optionId,
price: o.price ?? undefined,
pricePercent: o.pricePercent ?? undefined,
priceRange: o.priceRange ?? undefined,
})),
extras: type.extras.map((e) => ({
extraId: e.extraId,
price: e.price ?? undefined,
pricePercent: e.pricePercent ?? undefined,
priceRange: e.priceRange ?? undefined,
})),
customInputs: type.customInputs.map((f) => ({
fieldId: f.customInputId,
fieldType: f.inputType,
label: f.label,
name: f.customInput.name,
required: f.required,
})),
},
})
async function onSubmit(values: z.infer<typeof commissionTypeSchema>) {
try {
await updateCommissionType(type.id, values)
toast.success("Commission type updated.")
router.push("/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={allOptions} />
<CommissionExtraField extras={allExtras} />
<CommissionCustomInputField customInputs={allCustomInputs} />
<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,190 @@
"use client"
import { deleteCommissionType } from "@/actions/commissions/types/deleteType";
import { updateCommissionTypeSortOrder } from "@/actions/commissions/types/updateCommissionTypeSortOrder";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext
} from "@dnd-kit/sortable";
import { PencilIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import SortableItemCard from "./SortableItemCard";
type CommissionTypeWithItems = CommissionType & {
options: (CommissionTypeOption & {
option: CommissionOption | null
})[]
extras: (CommissionTypeExtra & {
extra: CommissionExtra | null
})[],
customInputs: (CommissionTypeCustomInput & {
customInput: CommissionCustomInput
})[]
}
export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) {
const [items, setItems] = useState(types)
const [isMounted, setIsMounted] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
useEffect(() => {
setIsMounted(true)
}, [])
const sensors = useSensors(useSensor(PointerSensor))
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((i) => i.id === active.id)
const newIndex = items.findIndex((i) => i.id === over.id)
if (oldIndex !== -1 && newIndex !== -1) {
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
await updateCommissionTypeSortOrder(newItems.map((item, i) => ({ id: item.id, sortIndex: i })))
}
}
const confirmDelete = () => {
if (!deleteTargetId) return
startTransition(async () => {
await deleteCommissionType(deleteTargetId)
setItems((prev) => prev.filter((i) => i.id !== deleteTargetId))
setDialogOpen(false)
setDeleteTargetId(null)
})
}
if (!isMounted) return null
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map(type => (
<SortableItemCard key={type.id} id={type.id}>
<Card>
<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>
<div>
<h4 className="font-semibold">Custom Inputs</h4>
<ul className="pl-4 list-disc">
{type.customInputs.map((ci) => (
<li key={ci.id}>
{ci.label}: {ci.inputType}
</li>
))}
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Link
href={`/commissions/types/${type.id}`}
className="w-full"
>
<Button variant="default" className="w-full flex items-center gap-2">
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="destructive"
className="w-full flex items-center gap-2"
onClick={() => {
setDeleteTargetId(type.id)
setDialogOpen(true)
}}
>
<TrashIcon className="h-4 w-4" />
Delete
</Button>
</CardFooter>
</Card>
</SortableItemCard >
))}
</SortableContext>
</DndContext>
</div >
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this commission type?</DialogTitle>
</DialogHeader>
<p>This action cannot be undone. Are you sure you want to continue?</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={isPending}
onClick={confirmDelete}
>
Confirm Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,93 @@
"use client"
import { createCommissionType } from "@/actions/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 { CommissionCustomInput, CommissionExtra, CommissionOption } from "@/generated/prisma/client";
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 { CommissionCustomInputField } from "./form/CommissionCustomInputField";
import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField";
type Props = {
options: CommissionOption[],
extras: CommissionExtra[],
customInputs: CommissionCustomInput[]
}
export default function NewTypeForm({ options, extras, customInputs }: 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)
toast("Commission type created.")
router.push("/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} />
<CommissionCustomInputField customInputs={customInputs} />
<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,49 @@
import {
useSortable
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
export default function SortableItem({
id,
children,
}: {
id: string
children: React.ReactNode
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : "auto",
opacity: isDragging ? 0.7 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={`transition-all duration-200 ease-in-out ${isDragging ? "ring-2 ring-primary rounded-md shadow-lg" : ""
}`}
>
<div className="flex items-start gap-2 min-h-16">
<div
{...listeners}
{...attributes}
className="cursor-grab px-1 pt-2 text-muted-foreground select-none"
title="Drag to reorder"
>
</div>
<div className="w-full">{children}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
"use client"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ReactNode } from "react"
type Props = {
id: string
children: ReactNode
}
export default function SortableItemCard({ id, children }: Props) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 40 : "auto",
opacity: isDragging ? 0.7 : 1,
}
return (
<div
ref={setNodeRef}
style={style}>
<div
{...attributes}
{...listeners}
className="cursor-grab px-2 py-1 text-sm text-muted-foreground"
title="Drag to reorder"
>
</div>
{children}
</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-75 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,205 @@
"use client"
import { createCommissionCustomInput } from "@/actions/commissions/types/newType"
import { Button } from "@/components/ui/button"
import {
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { CommissionCustomInput } from "@/generated/prisma/client"
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { useEffect, useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import SortableItem from "../SortableItem"
import { ComboboxCreateable } from "./ComboboxCreateable"
type Props = {
customInputs: CommissionCustomInput[]
}
export function CommissionCustomInputField({ customInputs: initialInputs }: Props) {
const [mounted, setMounted] = useState(false)
const { control, setValue } = useFormContext()
const { fields, append, remove, move } = useFieldArray({
control,
name: "customInputs",
})
const [customInputs, setCustomInputs] = useState(initialInputs)
const sensors = useSensors(useSensor(PointerSensor))
useEffect(() => {
setMounted(true)
}, [])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = fields.findIndex((f) => f.id === active.id)
const newIndex = fields.findIndex((f) => f.id === over.id)
if (oldIndex !== -1 && newIndex !== -1) {
move(oldIndex, newIndex)
}
}
if (!mounted) return null
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Custom Inputs</h3>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{fields.map((field, index) => {
return (
<SortableItem key={field.id} id={field.id}>
<div className="grid grid-cols-5 gap-4 items-end">
{/* Picker */}
<FormField
control={control}
name={`customInputs.${index}.customInputId`}
render={({ field: inputField }) => (
<FormItem>
<FormLabel>Input</FormLabel>
<FormControl>
<ComboboxCreateable
options={customInputs.map((ci) => ({
label: ci.name,
value: ci.id,
}))}
selected={inputField.value}
onSelect={(val) => {
const selected = customInputs.find((ci) => ci.id === val)
inputField.onChange(val)
if (selected) {
setValue(`customInputs.${index}.label`, selected.name)
setValue(`customInputs.${index}.inputType`, "text")
setValue(`customInputs.${index}.required`, false)
}
}}
onCreateNew={async (name) => {
const slug = name.toLowerCase().replace(/\s+/g, "-")
const newInput = await createCommissionCustomInput({
name,
fieldId: slug,
})
setCustomInputs((prev) => [...prev, newInput])
inputField.onChange(newInput.id)
setValue(`customInputs.${index}.label`, newInput.name)
setValue(`customInputs.${index}.inputType`, "text")
setValue(`customInputs.${index}.required`, false)
}}
/>
</FormControl>
</FormItem>
)}
/>
{/* Label */}
<FormField
control={control}
name={`customInputs.${index}.label`}
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
{/* Input Type */}
<FormField
control={control}
name={`customInputs.${index}.inputType`}
render={({ field }) => (
<FormItem>
<FormLabel>Input Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select input type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="textarea">Textarea</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="checkbox">Checkbox</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="select">Dropdown (Select)</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Required */}
<FormField
control={control}
name={`customInputs.${index}.required`}
render={({ field }) => (
<FormItem>
<FormLabel>Required</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{/* Remove */}
<Button type="button" variant="destructive" onClick={() => remove(index)}>
Remove
</Button>
</div>
</SortableItem>
)
})}
</SortableContext>
</DndContext>
<Button
type="button"
onClick={() =>
append({
customInputId: "",
label: "",
inputType: "text",
required: false,
})
}
>
Add Input
</Button>
</div>
)
}

View File

@ -0,0 +1,206 @@
"use client"
import { createCommissionExtra } from "@/actions/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/client"
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { useEffect, useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import SortableItem from "../SortableItem"
import { ComboboxCreateable } from "./ComboboxCreateable"
type Props = {
extras: CommissionExtra[]
}
export function CommissionExtraField({ extras: initialExtras }: Props) {
const [mounted, setMounted] = useState(false)
const { control } = useFormContext()
const { fields, append, remove, move } = useFieldArray({
control,
name: "extras",
})
useEffect(() => {
setMounted(true)
}, [])
const [extras, setExtras] = useState(initialExtras)
const sensors = useSensors(useSensor(PointerSensor))
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const oldIndex = fields.findIndex((f) => f.id === active.id)
const newIndex = fields.findIndex((f) => f.id === over.id)
if (oldIndex !== -1 && newIndex !== -1) {
move(oldIndex, newIndex)
}
}
if (!mounted) return null
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Extras</h3>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{fields.map((field, index) => (
<SortableItem key={field.id} id={field.id}>
<div 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
label={(value) => value}
value={[start, end]}
onValueChange={([min, max]) =>
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
}
min={0}
max={100}
step={1}
/>
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
</SortableItem>
))}
</SortableContext>
</DndContext >
<Button
type="button"
onClick={() =>
append({
extraId: "",
price: undefined,
pricePercent: undefined,
priceRange: "00",
})
}
>
Add Extra
</Button>
</div >
)
}

View File

@ -0,0 +1,208 @@
"use client"
import { createCommissionOption } from "@/actions/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/client"
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { useEffect, useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form"
import SortableItem from "../SortableItem"
import { ComboboxCreateable } from "./ComboboxCreateable"
type Props = {
options: CommissionOption[]
}
export function CommissionOptionField({ options: initialOptions }: Props) {
const [mounted, setMounted] = useState(false)
const { control } = useFormContext()
const { fields, append, remove, move } = useFieldArray({
control,
name: "options",
})
useEffect(() => {
setMounted(true)
}, [])
const [options, setOptions] = useState(initialOptions)
const sensors = useSensors(useSensor(PointerSensor))
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const oldIndex = fields.findIndex((f) => f.id === active.id)
const newIndex = fields.findIndex((f) => f.id === over.id)
if (oldIndex !== -1 && newIndex !== -1) {
move(oldIndex, newIndex)
}
}
if (!mounted) return null
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Options</h3>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{fields.map((field, index) => (
<SortableItem key={field.id} id={field.id}>
<div 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
label={(value) => value}
value={[start, end]}
onValueChange={([min, max]) =>
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
}
min={0}
max={100}
step={1}
/>
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
</SortableItem>
))}
</SortableContext>
</DndContext>
{/* Add Button */}
<Button
type="button"
onClick={() =>
append({
optionId: "",
price: undefined,
pricePercent: undefined,
priceRange: "00",
})
}
>
Add Option
</Button>
</div>
)
}

View File

@ -103,6 +103,12 @@ export default function TopNav() {
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/commissions">Commissions</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{/* <NavigationMenuItem>
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
<NavigationMenuContent>

View File

@ -0,0 +1,50 @@
'use client';
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
interface DualRangeSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {
labelPosition?: 'top' | 'bottom';
label?: (value: number | undefined) => React.ReactNode;
}
const DualRangeSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
DualRangeSliderProps
>(({ className, label, labelPosition = 'top', ...props }, ref) => {
const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max];
return (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{initialValue.map((value, index) => (
<React.Fragment key={index}>
<SliderPrimitive.Thumb className="relative block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50">
{label && (
<span
className={cn(
'absolute flex w-full justify-center',
labelPosition === 'top' && '-top-7',
labelPosition === 'bottom' && 'top-4',
)}
>
{label(value)}
</span>
)}
</SliderPrimitive.Thumb>
</React.Fragment>
))}
</SliderPrimitive.Root>
);
});
DualRangeSlider.displayName = 'DualRangeSlider';
export { DualRangeSlider };

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -0,0 +1,34 @@
import * as z from "zod/v4";
const rangePattern = /^\d{1,3}\d{1,3}$/;
const optionField = z.object({
optionId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z.string().regex(rangePattern, "Format must be like '1080'").optional(),
});
const extraField = z.object({
extraId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z.string().regex(rangePattern, "Format must be like '1080'").optional(),
});
const customInputsField = z.object({
customInputId: z.string(),
inputType: z.string(),
label: z.string(),
required: z.boolean(),
});
export const commissionTypeSchema = z.object({
name: z.string().min(1, "Name is required. Min 1 character."),
description: z.string().optional(),
options: z.array(optionField).optional(),
extras: z.array(extraField).optional(),
customInputs: z.array(customInputsField).optional(),
})
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>