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

@ -6,6 +6,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.954.0",
"@aws-sdk/s3-request-presigner": "^3.954.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
@ -17,6 +19,7 @@
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@ -173,6 +176,14 @@
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.2", "", {}, "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w=="],
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.6", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.2" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw=="],
@ -369,6 +380,8 @@
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],

View File

@ -12,6 +12,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.954.0",
"@aws-sdk/s3-request-presigner": "^3.954.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
@ -23,6 +25,7 @@
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",

View File

@ -0,0 +1,142 @@
-- CreateTable
CREATE TABLE "Commission" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Commission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionType" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionOption" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeOption" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"optionId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionTypeOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionExtra" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionExtra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeExtra" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"extraId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionTypeExtra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomInput" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
CONSTRAINT "CommissionCustomInput_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeCustomInput" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"customInputId" TEXT NOT NULL,
"inputType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "CommissionTypeCustomInput_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionRequest" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "CommissionRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeOption_typeId_optionId_key" ON "CommissionTypeOption"("typeId", "optionId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeExtra_typeId_extraId_key" ON "CommissionTypeExtra"("typeId", "extraId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionCustomInput_name_key" ON "CommissionCustomInput"("name");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeCustomInput_typeId_customInputId_key" ON "CommissionTypeCustomInput"("typeId", "customInputId");
-- AddForeignKey
ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_customInputId_fkey" FOREIGN KEY ("customInputId") REFERENCES "CommissionCustomInput"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -222,3 +222,127 @@ model FileVariant {
@@unique([artworkId, type])
}
model Commission {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
}
model CommissionType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
options CommissionTypeOption[]
extras CommissionTypeExtra[]
customInputs CommissionTypeCustomInput[]
}
model CommissionOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
types CommissionTypeOption[]
}
model CommissionTypeOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
optionId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
option CommissionOption @relation(fields: [optionId], references: [id])
@@unique([typeId, optionId])
}
model CommissionExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
types CommissionTypeExtra[]
}
model CommissionTypeExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
extraId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
extra CommissionExtra @relation(fields: [extraId], references: [id])
@@unique([typeId, extraId])
}
model CommissionCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
fieldId String
types CommissionTypeCustomInput[]
}
model CommissionTypeCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
customInputId String
inputType String
label String
required Boolean @default(false)
type CommissionType @relation(fields: [typeId], references: [id])
customInput CommissionCustomInput @relation(fields: [customInputId], references: [id])
@@unique([typeId, customInputId])
}
model CommissionRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
}

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>