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,
description: data.description,
options: {
create: data.options?.map((opt) => ({
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) => ({
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price,
pricePercent: ext.pricePercent,
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() {
const types = await prisma.commissionType.findMany({
include: {
options: { include: { option: true } },
extras: { include: { extra: true } },
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "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"
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 { 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 & {
options: (CommissionTypeOption & {
@ -13,52 +29,85 @@ type CommissionTypeWithItems = CommissionType & {
}
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 (
<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>
))}
<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>
</CardContent>
</Card>
</SortableItemCard >
))}
</SortableContext>
</DndContext>
</div>
);
}

View File

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

View File

@ -1,6 +1,7 @@
"use client"
import { createCommissionExtra } from "@/actions/items/commissions/types/newType"
import SortableItem from "@/components/drag/SortableItem"
import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range"
import {
@ -11,6 +12,18 @@ import {
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
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 { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable"
@ -21,125 +34,148 @@ type Props = {
export function CommissionExtraField({ extras: initialExtras }: Props) {
const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({
const { fields, append, remove, move } = useFieldArray({
control,
name: "extras",
})
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 (
<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>
)}
/>
<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 */}
<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>
)}
/>
{/* 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]
{/* 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>
)
}}
/>
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>
))}
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
</SortableItem>
))}
</SortableContext>
</DndContext >
<Button
type="button"
@ -154,6 +190,6 @@ export function CommissionExtraField({ extras: initialExtras }: Props) {
>
Add Extra
</Button>
</div>
</div >
)
}

View File

@ -1,6 +1,7 @@
"use client"
import { createCommissionOption } from "@/actions/items/commissions/types/newType"
import SortableItem from "@/components/drag/SortableItem"
import { Button } from "@/components/ui/button"
import { DualRangeSlider } from "@/components/ui/dual-range"
import {
@ -11,6 +12,18 @@ import {
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
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 { useFieldArray, useFormContext } from "react-hook-form"
import { ComboboxCreateable } from "./ComboboxCreateable"
@ -21,125 +34,149 @@ type Props = {
export function CommissionOptionField({ options: initialOptions }: Props) {
const { control } = useFormContext()
const { fields, append, remove } = useFieldArray({
const { fields, append, remove, move } = useFieldArray({
control,
name: "options",
})
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 (
<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>
)}
/>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
{/* 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>
)}
/>
{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 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 */}
<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 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]
{/* 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>
)}
/>
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>
)
}}
/>
{/* 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]
{/* Remove Button */}
<Button
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
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>
</SortableItem>
))}
</SortableContext>
</DndContext>
{/* Add Button */}
<Button