From a0b515366ab64009f56aeecc88f971e1a208ae49 Mon Sep 17 00:00:00 2001 From: Citali Date: Sun, 6 Jul 2025 00:31:41 +0200 Subject: [PATCH] Finished adding new commissionType --- .../items/commissions/types/newType.ts | 6 +- .../types/updateCommissionTypeSortOrder.ts | 16 ++ src/app/items/commissions/types/page.tsx | 4 +- src/components/drag/SortableItem.tsx | 49 ++++ src/components/drag/SortableItemCard.tsx | 40 +++ .../items/commissions/types/ListTypes.tsx | 137 ++++++---- .../items/commissions/types/NewTypeForm.tsx | 1 + .../types/form/CommissionExtraField.tsx | 246 ++++++++++-------- .../types/form/CommissionOptionField.tsx | 245 +++++++++-------- 9 files changed, 487 insertions(+), 257 deletions(-) create mode 100644 src/actions/items/commissions/types/updateCommissionTypeSortOrder.ts create mode 100644 src/components/drag/SortableItem.tsx create mode 100644 src/components/drag/SortableItemCard.tsx diff --git a/src/actions/items/commissions/types/newType.ts b/src/actions/items/commissions/types/newType.ts index 5a399a7..b9db59b 100644 --- a/src/actions/items/commissions/types/newType.ts +++ b/src/actions/items/commissions/types/newType.ts @@ -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, })) || [], }, }, diff --git a/src/actions/items/commissions/types/updateCommissionTypeSortOrder.ts b/src/actions/items/commissions/types/updateCommissionTypeSortOrder.ts new file mode 100644 index 0000000..6ac70ad --- /dev/null +++ b/src/actions/items/commissions/types/updateCommissionTypeSortOrder.ts @@ -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) +} \ No newline at end of file diff --git a/src/app/items/commissions/types/page.tsx b/src/app/items/commissions/types/page.tsx index e518f69..0314564 100644 --- a/src/app/items/commissions/types/page.tsx +++ b/src/app/items/commissions/types/page.tsx @@ -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" }], }); diff --git a/src/components/drag/SortableItem.tsx b/src/components/drag/SortableItem.tsx new file mode 100644 index 0000000..d5a84fe --- /dev/null +++ b/src/components/drag/SortableItem.tsx @@ -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 ( +
+
+
+ ☰ +
+
{children}
+
+
+ ) +} diff --git a/src/components/drag/SortableItemCard.tsx b/src/components/drag/SortableItemCard.tsx new file mode 100644 index 0000000..b065b8f --- /dev/null +++ b/src/components/drag/SortableItemCard.tsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/src/components/items/commissions/types/ListTypes.tsx b/src/components/items/commissions/types/ListTypes.tsx index 9066c80..589ee76 100644 --- a/src/components/items/commissions/types/ListTypes.tsx +++ b/src/components/items/commissions/types/ListTypes.tsx @@ -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 (
- {types.map(type => ( - - - {type.name} - {type.description} - - -
-

Options

-
    - {type.options.map((opt) => ( -
  • - {opt.option?.name}:{" "} - {opt.price !== null - ? `${opt.price}€` - : opt.pricePercent - ? `+${opt.pricePercent}%` - : opt.priceRange - ? `${opt.priceRange}€` - : "Included"} -
  • - ))} -
-
-
-

Extras

-
    - {type.extras.map((ext) => ( -
  • - {ext.extra?.name}:{" "} - {ext.price !== null - ? `${ext.price}€` - : ext.pricePercent - ? `+${ext.pricePercent}%` - : ext.priceRange - ? `${ext.priceRange}€` - : "Included"} -
  • - ))} -
-
-
-
- ))} + + i.id)} strategy={rectSortingStrategy}> + {items.map(type => ( + + + + {type.name} + {type.description} + + +
+

Options

+
    + {type.options.map((opt) => ( +
  • + {opt.option?.name}:{" "} + {opt.price !== null + ? `${opt.price}€` + : opt.pricePercent + ? `+${opt.pricePercent}%` + : opt.priceRange + ? `${opt.priceRange}€` + : "Included"} +
  • + ))} +
+
+
+

Extras

+
    + {type.extras.map((ext) => ( +
  • + {ext.extra?.name}:{" "} + {ext.price !== null + ? `${ext.price}€` + : ext.pricePercent + ? `+${ext.pricePercent}%` + : ext.priceRange + ? `${ext.priceRange}€` + : "Included"} +
  • + ))} +
+
+
+
+
+ ))} +
+
); } \ No newline at end of file diff --git a/src/components/items/commissions/types/NewTypeForm.tsx b/src/components/items/commissions/types/NewTypeForm.tsx index 14d202d..a6e5a2e 100644 --- a/src/components/items/commissions/types/NewTypeForm.tsx +++ b/src/components/items/commissions/types/NewTypeForm.tsx @@ -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) diff --git a/src/components/items/commissions/types/form/CommissionExtraField.tsx b/src/components/items/commissions/types/form/CommissionExtraField.tsx index 4a6559e..fa14594 100644 --- a/src/components/items/commissions/types/form/CommissionExtraField.tsx +++ b/src/components/items/commissions/types/form/CommissionExtraField.tsx @@ -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 (

Extras

- {fields.map((field, index) => ( -
- {/* Extra Picker (combobox with create) */} - ( - - Extra - - ({ - 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) - }} - /> - - - )} - /> + + f.id)} strategy={verticalListSortingStrategy}> + {fields.map((field, index) => ( + +
+ {/* Extra Picker (combobox with create) */} + ( + + Extra + + ({ + 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) + }} + /> + + + )} + /> - {/* Price */} - ( - - Price (€) - - - field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) - } - /> - - - )} - /> + {/* Price */} + ( + + Price (€) + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> - {/* Price Percent */} - ( - - + % - - - field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) - } - /> - - - )} - /> + {/* Price Percent */} + ( + + + % + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> - {/* Range Slider */} - { - const [start, end] = - typeof field.value === "string" && field.value.includes("–") - ? field.value.split("–").map(Number) - : [0, 0] + {/* Range Slider */} + { + const [start, end] = + typeof field.value === "string" && field.value.includes("–") + ? field.value.split("–").map(Number) + : [0, 0] - return ( - - Range - - - field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) - } - /> - - - ) - }} - /> + return ( + + Range + + + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + /> + + + ) + }} + /> - {/* Remove Button */} - -
- ))} + {/* Remove Button */} + +
+ + ))} + + -
+ ) } diff --git a/src/components/items/commissions/types/form/CommissionOptionField.tsx b/src/components/items/commissions/types/form/CommissionOptionField.tsx index 50f9746..4778fad 100644 --- a/src/components/items/commissions/types/form/CommissionOptionField.tsx +++ b/src/components/items/commissions/types/form/CommissionOptionField.tsx @@ -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 (

Options

- {fields.map((field, index) => ( -
- {/* Option Picker (combobox with create) */} - ( - - Option - - ({ - 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) - }} - /> - - - )} - /> + + f.id)} strategy={verticalListSortingStrategy}> - {/* Price */} - ( - - Price (€) - - - field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) - } - /> - - - )} - /> + {fields.map((field, index) => ( + +
+ {/* Option Picker (combobox with create) */} + ( + + Option + + ({ + 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) + }} + /> + + + )} + /> - {/* Price Percent */} - ( - - + % - - - field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) - } - /> - - - )} - /> + {/* Price */} + ( + + Price (€) + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> - {/* Price Range Slider */} - { - const [start, end] = - typeof field.value === "string" && field.value.includes("–") - ? field.value.split("–").map(Number) - : [0, 0] + {/* Price Percent */} + ( + + + % + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> - return ( - - Range - - - field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) - } - /> - - - ) - }} - /> + {/* Price Range Slider */} + { + const [start, end] = + typeof field.value === "string" && field.value.includes("–") + ? field.value.split("–").map(Number) + : [0, 0] - {/* Remove Button */} - -
- ))} + return ( + + Range + + + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + /> + + + ) + }} + /> + + {/* Remove Button */} + +
+ + ))} + + {/* Add Button */}