Finished adding new commissionType

This commit is contained in:
2025-07-06 00:31:41 +02:00
parent 648ecda8d4
commit a0b515366a
9 changed files with 487 additions and 257 deletions

View File

@ -36,19 +36,21 @@ export async function createCommissionType(formData: commissionTypeSchema) {
name: data.name, name: data.name,
description: data.description, description: data.description,
options: { options: {
create: data.options?.map((opt) => ({ create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } }, option: { connect: { id: opt.optionId } },
price: opt.price, price: opt.price,
pricePercent: opt.pricePercent, pricePercent: opt.pricePercent,
priceRange: opt.priceRange, priceRange: opt.priceRange,
sortIndex: index,
})) || [], })) || [],
}, },
extras: { extras: {
create: data.extras?.map((ext) => ({ create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } }, extra: { connect: { id: ext.extraId } },
price: ext.price, price: ext.price,
pricePercent: ext.pricePercent, pricePercent: ext.pricePercent,
priceRange: ext.priceRange, priceRange: ext.priceRange,
sortIndex: index,
})) || [], })) || [],
}, },
}, },

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

@ -6,8 +6,8 @@ import Link from "next/link";
export default async function CommissionTypesPage() { export default async function CommissionTypesPage() {
const types = await prisma.commissionType.findMany({ const types = await prisma.commissionType.findMany({
include: { include: {
options: { include: { option: true } }, options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true } }, extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
}, },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}); });

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, // 👈 the key to handle-only dragging
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-[4rem]">
<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,40 @@
"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}
{...attributes}
{...listeners}
className="cursor-grab"
>
{children}
</div>
)
}

View File

@ -1,7 +1,23 @@
"use client" "use client"
import { updateCommissionTypeSortOrder } from "@/actions/items/commissions/types/updateCommissionTypeSortOrder";
import SortableItemCard from "@/components/drag/SortableItemCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma"; import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma";
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
rectSortingStrategy,
SortableContext
} from "@dnd-kit/sortable";
import { useEffect, useState } from "react";
type CommissionTypeWithItems = CommissionType & { type CommissionTypeWithItems = CommissionType & {
options: (CommissionTypeOption & { options: (CommissionTypeOption & {
@ -13,52 +29,85 @@ type CommissionTypeWithItems = CommissionType & {
} }
export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) { export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) {
const [items, setItems] = useState(types)
const [isMounted, setIsMounted] = useState(false)
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 })))
}
}
if (!isMounted) return null
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{types.map(type => ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Card key={type.id}> <SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
<CardHeader> {items.map(type => (
<CardTitle className="text-xl truncate">{type.name}</CardTitle> <SortableItemCard key={type.id} id={type.id}>
<CardDescription>{type.description}</CardDescription> <Card>
</CardHeader> <CardHeader>
<CardContent className="flex flex-col justify-start gap-4"> <CardTitle className="text-xl truncate">{type.name}</CardTitle>
<div> <CardDescription>{type.description}</CardDescription>
<h4 className="font-semibold">Options</h4> </CardHeader>
<ul className="pl-4 list-disc"> <CardContent className="flex flex-col justify-start gap-4">
{type.options.map((opt) => ( <div>
<li key={opt.id}> <h4 className="font-semibold">Options</h4>
{opt.option?.name}:{" "} <ul className="pl-4 list-disc">
{opt.price !== null {type.options.map((opt) => (
? `${opt.price}` <li key={opt.id}>
: opt.pricePercent {opt.option?.name}:{" "}
? `+${opt.pricePercent}%` {opt.price !== null
: opt.priceRange ? `${opt.price}`
? `${opt.priceRange}` : opt.pricePercent
: "Included"} ? `+${opt.pricePercent}%`
</li> : opt.priceRange
))} ? `${opt.priceRange}`
</ul> : "Included"}
</div> </li>
<div> ))}
<h4 className="font-semibold">Extras</h4> </ul>
<ul className="pl-4 list-disc"> </div>
{type.extras.map((ext) => ( <div>
<li key={ext.id}> <h4 className="font-semibold">Extras</h4>
{ext.extra?.name}:{" "} <ul className="pl-4 list-disc">
{ext.price !== null {type.extras.map((ext) => (
? `${ext.price}` <li key={ext.id}>
: ext.pricePercent {ext.extra?.name}:{" "}
? `+${ext.pricePercent}%` {ext.price !== null
: ext.priceRange ? `${ext.price}`
? `${ext.priceRange}` : ext.pricePercent
: "Included"} ? `+${ext.pricePercent}%`
</li> : ext.priceRange
))} ? `${ext.priceRange}`
</ul> : "Included"}
</div> </li>
</CardContent> ))}
</Card> </ul>
))} </div>
</CardContent>
</Card>
</SortableItemCard >
))}
</SortableContext>
</DndContext>
</div> </div>
); );
} }

View File

@ -35,6 +35,7 @@ export default function NewTypeForm({ options, extras }: Props) {
try { try {
const created = await createCommissionType(values) const created = await createCommissionType(values)
console.log("CommissionType created:", created) console.log("CommissionType created:", created)
toast("Commission type created.")
router.push("/items/commissions/types") router.push("/items/commissions/types")
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { createCommissionExtra } from "@/actions/items/commissions/types/newType" import { createCommissionExtra } from "@/actions/items/commissions/types/newType"
import SortableItem from "@/components/drag/SortableItem"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range" import { DualRangeSlider } from "@/components/ui/dual-range"
import { import {
@ -11,6 +12,18 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { CommissionExtra } from "@/generated/prisma" import { CommissionExtra } from "@/generated/prisma"
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { useState } from "react" import { useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form" import { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable" import { ComboboxCreateable } from "./ComboboxCreateable"
@ -21,125 +34,148 @@ type Props = {
export function CommissionExtraField({ extras: initialExtras }: Props) { export function CommissionExtraField({ extras: initialExtras }: Props) {
const { control } = useFormContext() const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({ const { fields, append, remove, move } = useFieldArray({
control, control,
name: "extras", name: "extras",
}) })
const [extras, setExtras] = useState(initialExtras) 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)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">Extras</h3> <h3 className="text-lg font-semibold">Extras</h3>
{fields.map((field, index) => ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div key={field.id} className="grid grid-cols-5 gap-4 items-end"> <SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{/* Extra Picker (combobox with create) */} {fields.map((field, index) => (
<FormField <SortableItem key={field.id} id={field.id}>
control={control} <div className="grid grid-cols-5 gap-4 items-end">
name={`extras.${index}.extraId`} {/* Extra Picker (combobox with create) */}
render={({ field: extraField }) => ( <FormField
<FormItem> control={control}
<FormLabel>Extra</FormLabel> name={`extras.${index}.extraId`}
<FormControl> render={({ field: extraField }) => (
<ComboboxCreateable <FormItem>
options={extras.map((e) => ({ <FormLabel>Extra</FormLabel>
label: e.name, <FormControl>
value: e.id, <ComboboxCreateable
}))} options={extras.map((e) => ({
selected={extraField.value} label: e.name,
onSelect={extraField.onChange} value: e.id,
onCreateNew={async (name) => { }))}
const newExtra = await createCommissionExtra({ name }) selected={extraField.value}
setExtras((prev) => [...prev, newExtra]) onSelect={extraField.onChange}
extraField.onChange(newExtra.id) onCreateNew={async (name) => {
}} const newExtra = await createCommissionExtra({ name })
/> setExtras((prev) => [...prev, newExtra])
</FormControl> extraField.onChange(newExtra.id)
</FormItem> }}
)} />
/> </FormControl>
</FormItem>
)}
/>
{/* Price */} {/* Price */}
<FormField <FormField
control={control} control={control}
name={`extras.${index}.price`} name={`extras.${index}.price`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Price ()</FormLabel> <FormLabel>Price ()</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
} }
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
{/* Price Percent */} {/* Price Percent */}
<FormField <FormField
control={control} control={control}
name={`extras.${index}.pricePercent`} name={`extras.${index}.pricePercent`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>+ %</FormLabel> <FormLabel>+ %</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
} }
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
{/* Range Slider */} {/* Range Slider */}
<FormField <FormField
control={control} control={control}
name={`extras.${index}.priceRange`} name={`extras.${index}.priceRange`}
render={({ field }) => { render={({ field }) => {
const [start, end] = const [start, end] =
typeof field.value === "string" && field.value.includes("") typeof field.value === "string" && field.value.includes("")
? field.value.split("").map(Number) ? field.value.split("").map(Number)
: [0, 0] : [0, 0]
return ( return (
<FormItem> <FormItem>
<FormLabel>Range</FormLabel> <FormLabel>Range</FormLabel>
<FormControl> <FormControl>
<DualRangeSlider <DualRangeSlider
value={[start, end]} value={[start, end]}
onChange={([min, max]) => onChange={([min, max]) =>
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`) field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
} }
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
) )
}} }}
/> />
{/* Remove Button */} {/* Remove Button */}
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => remove(index)} onClick={() => remove(index)}
> >
Remove Remove
</Button> </Button>
</div> </div>
))} </SortableItem>
))}
</SortableContext>
</DndContext >
<Button <Button
type="button" type="button"
@ -154,6 +190,6 @@ export function CommissionExtraField({ extras: initialExtras }: Props) {
> >
Add Extra Add Extra
</Button> </Button>
</div> </div >
) )
} }

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { createCommissionOption } from "@/actions/items/commissions/types/newType" import { createCommissionOption } from "@/actions/items/commissions/types/newType"
import SortableItem from "@/components/drag/SortableItem"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range" import { DualRangeSlider } from "@/components/ui/dual-range"
import { import {
@ -11,6 +12,18 @@ import {
} from "@/components/ui/form" } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { CommissionOption } from "@/generated/prisma" import { CommissionOption } from "@/generated/prisma"
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { useState } from "react" import { useState } from "react"
import { useFieldArray, useFormContext } from "react-hook-form" import { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable" import { ComboboxCreateable } from "./ComboboxCreateable"
@ -21,125 +34,149 @@ type Props = {
export function CommissionOptionField({ options: initialOptions }: Props) { export function CommissionOptionField({ options: initialOptions }: Props) {
const { control } = useFormContext() const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({ const { fields, append, remove, move } = useFieldArray({
control, control,
name: "options", name: "options",
}) })
const [options, setOptions] = useState(initialOptions) 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)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">Options</h3> <h3 className="text-lg font-semibold">Options</h3>
{fields.map((field, index) => ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div key={field.id} className="grid grid-cols-5 gap-4 items-end"> <SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{/* 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 */} {fields.map((field, index) => (
<FormField <SortableItem key={field.id} id={field.id}>
control={control} <div className="grid grid-cols-5 gap-4 items-end">
name={`options.${index}.price`} {/* Option Picker (combobox with create) */}
render={({ field }) => ( <FormField
<FormItem> control={control}
<FormLabel>Price ()</FormLabel> name={`options.${index}.optionId`}
<FormControl> render={({ field: optionField }) => (
<Input <FormItem>
type="number" <FormLabel>Option</FormLabel>
step="0.01" <FormControl>
{...field} <ComboboxCreateable
value={field.value ?? ""} options={options.map((o) => ({
onChange={(e) => label: o.name,
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) value: o.id,
} }))}
/> selected={optionField.value}
</FormControl> onSelect={optionField.onChange}
</FormItem> onCreateNew={async (name) => {
)} const newOption = await createCommissionOption({ name })
/> setOptions((prev) => [...prev, newOption])
optionField.onChange(newOption.id)
}}
/>
</FormControl>
</FormItem>
)}
/>
{/* Price Percent */} {/* Price */}
<FormField <FormField
control={control} control={control}
name={`options.${index}.pricePercent`} name={`options.${index}.price`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>+ %</FormLabel> <FormLabel>Price ()</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
} }
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
{/* Price Range Slider */} {/* Price Percent */}
<FormField <FormField
control={control} control={control}
name={`options.${index}.priceRange`} name={`options.${index}.pricePercent`}
render={({ field }) => { render={({ field }) => (
const [start, end] = <FormItem>
typeof field.value === "string" && field.value.includes("") <FormLabel>+ %</FormLabel>
? field.value.split("").map(Number) <FormControl>
: [0, 0] <Input
type="number"
step="0.01"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber)
}
/>
</FormControl>
</FormItem>
)}
/>
return ( {/* Price Range Slider */}
<FormItem> <FormField
<FormLabel>Range</FormLabel> control={control}
<FormControl> name={`options.${index}.priceRange`}
<DualRangeSlider render={({ field }) => {
value={[start, end]} const [start, end] =
onChange={([min, max]) => typeof field.value === "string" && field.value.includes("")
field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`) ? field.value.split("").map(Number)
} : [0, 0]
/>
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */} return (
<Button <FormItem>
type="button" <FormLabel>Range</FormLabel>
variant="destructive" <FormControl>
onClick={() => remove(index)} <DualRangeSlider
> value={[start, end]}
Remove onChange={([min, max]) =>
</Button> field.onChange(`${Math.min(min, max)}${Math.max(min, max)}`)
</div> }
))} />
</FormControl>
</FormItem>
)
}}
/>
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
</SortableItem>
))}
</SortableContext>
</DndContext>
{/* Add Button */} {/* Add Button */}
<Button <Button