From ee454261cbeab06290fa1df29b83caf131c0c1c3 Mon Sep 17 00:00:00 2001 From: Citali Date: Wed, 24 Dec 2025 00:52:47 +0100 Subject: [PATCH] Add commission types --- bun.lock | 13 ++ package.json | 3 + .../20251223230758_com_1/migration.sql | 142 ++++++++++++ prisma/schema.prisma | 124 +++++++++++ src/actions/commissions/types/deleteType.ts | 19 ++ src/actions/commissions/types/newType.ts | 81 +++++++ .../types/updateCommissionTypeSortOrder.ts | 16 ++ src/actions/commissions/types/updateType.ts | 57 +++++ src/app/commissions/page.tsx | 5 + src/app/commissions/types/[id]/page.tsx | 38 ++++ src/app/commissions/types/new/page.tsx | 24 ++ src/app/commissions/types/page.tsx | 27 +++ .../commissions/types/EditTypeForm.tsx | 116 ++++++++++ .../commissions/types/ListTypes.tsx | 190 ++++++++++++++++ .../commissions/types/NewTypeForm.tsx | 93 ++++++++ .../commissions/types/SortableItem.tsx | 49 +++++ .../commissions/types/SortableItemCard.tsx | 44 ++++ .../types/form/ComboboxCreateable.tsx | 111 ++++++++++ .../types/form/CommissionCustomInputField.tsx | 205 +++++++++++++++++ .../types/form/CommissionExtraField.tsx | 206 +++++++++++++++++ .../types/form/CommissionOptionField.tsx | 208 ++++++++++++++++++ src/components/global/TopNav.tsx | 6 + src/components/ui/dual-range.tsx | 50 +++++ src/components/ui/slider.tsx | 63 ++++++ src/schemas/commissionType.ts | 34 +++ 25 files changed, 1924 insertions(+) create mode 100644 prisma/migrations/20251223230758_com_1/migration.sql create mode 100644 src/actions/commissions/types/deleteType.ts create mode 100644 src/actions/commissions/types/newType.ts create mode 100644 src/actions/commissions/types/updateCommissionTypeSortOrder.ts create mode 100644 src/actions/commissions/types/updateType.ts create mode 100644 src/app/commissions/page.tsx create mode 100644 src/app/commissions/types/[id]/page.tsx create mode 100644 src/app/commissions/types/new/page.tsx create mode 100644 src/app/commissions/types/page.tsx create mode 100644 src/components/commissions/types/EditTypeForm.tsx create mode 100644 src/components/commissions/types/ListTypes.tsx create mode 100644 src/components/commissions/types/NewTypeForm.tsx create mode 100644 src/components/commissions/types/SortableItem.tsx create mode 100644 src/components/commissions/types/SortableItemCard.tsx create mode 100644 src/components/commissions/types/form/ComboboxCreateable.tsx create mode 100644 src/components/commissions/types/form/CommissionCustomInputField.tsx create mode 100644 src/components/commissions/types/form/CommissionExtraField.tsx create mode 100644 src/components/commissions/types/form/CommissionOptionField.tsx create mode 100644 src/components/ui/dual-range.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/schemas/commissionType.ts diff --git a/bun.lock b/bun.lock index 5e4e6cc..a3b28bb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 609c5ba..179c85a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20251223230758_com_1/migration.sql b/prisma/migrations/20251223230758_com_1/migration.sql new file mode 100644 index 0000000..e2423f5 --- /dev/null +++ b/prisma/migrations/20251223230758_com_1/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 68c4edb..bac4c71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) +} diff --git a/src/actions/commissions/types/deleteType.ts b/src/actions/commissions/types/deleteType.ts new file mode 100644 index 0000000..391923d --- /dev/null +++ b/src/actions/commissions/types/deleteType.ts @@ -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 }, + }) + +} \ No newline at end of file diff --git a/src/actions/commissions/types/newType.ts b/src/actions/commissions/types/newType.ts new file mode 100644 index 0000000..bc98a5a --- /dev/null +++ b/src/actions/commissions/types/newType.ts @@ -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 +} \ No newline at end of file diff --git a/src/actions/commissions/types/updateCommissionTypeSortOrder.ts b/src/actions/commissions/types/updateCommissionTypeSortOrder.ts new file mode 100644 index 0000000..bfd4fdd --- /dev/null +++ b/src/actions/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/actions/commissions/types/updateType.ts b/src/actions/commissions/types/updateType.ts new file mode 100644 index 0000000..99b8942 --- /dev/null +++ b/src/actions/commissions/types/updateType.ts @@ -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 +) { + 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 +} diff --git a/src/app/commissions/page.tsx b/src/app/commissions/page.tsx new file mode 100644 index 0000000..767df1d --- /dev/null +++ b/src/app/commissions/page.tsx @@ -0,0 +1,5 @@ +export default function CommissionPage() { + return ( +
CommissionPage
+ ); +} \ No newline at end of file diff --git a/src/app/commissions/types/[id]/page.tsx b/src/app/commissions/types/[id]/page.tsx new file mode 100644 index 0000000..2e1883d --- /dev/null +++ b/src/app/commissions/types/[id]/page.tsx @@ -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
Type not found
+ } + + return ( +
+
+

Edit Commission Type

+
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/commissions/types/new/page.tsx b/src/app/commissions/types/new/page.tsx new file mode 100644 index 0000000..aa82862 --- /dev/null +++ b/src/app/commissions/types/new/page.tsx @@ -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 ( +
+
+

New Commission Type

+
+ +
+ + ); +} \ No newline at end of file diff --git a/src/app/commissions/types/page.tsx b/src/app/commissions/types/page.tsx new file mode 100644 index 0000000..250d7f9 --- /dev/null +++ b/src/app/commissions/types/page.tsx @@ -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 ( +
+
+

Commission Types

+ + Add new Type + +
+ {types && types.length > 0 ? :

No types found.

} +
+ ); +} \ No newline at end of file diff --git a/src/components/commissions/types/EditTypeForm.tsx b/src/components/commissions/types/EditTypeForm.tsx new file mode 100644 index 0000000..e13de2f --- /dev/null +++ b/src/components/commissions/types/EditTypeForm.tsx @@ -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>({ + 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) { + 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 ( +
+
+ + ( + + Name + + + + The name of the commission type. + + + )} + /> + ( + + Description + + + + Optional description. + + + )} + /> + + + + + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/commissions/types/ListTypes.tsx b/src/components/commissions/types/ListTypes.tsx new file mode 100644 index 0000000..2a48b18 --- /dev/null +++ b/src/components/commissions/types/ListTypes.tsx @@ -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(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 ( + <> +
+ + 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"} +
  • + ))} +
+
+
+

Custom Inputs

+
    + {type.customInputs.map((ci) => ( +
  • + {ci.label}: {ci.inputType} +
  • + ))} +
+
+
+ + + + + + +
+
+ ))} +
+
+
+ + + + + Delete this commission type? + +

This action cannot be undone. Are you sure you want to continue?

+ + + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/components/commissions/types/NewTypeForm.tsx b/src/components/commissions/types/NewTypeForm.tsx new file mode 100644 index 0000000..04867b8 --- /dev/null +++ b/src/components/commissions/types/NewTypeForm.tsx @@ -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>({ + resolver: zodResolver(commissionTypeSchema), + defaultValues: { + name: "", + description: "", + options: [], + extras: [], + }, + }) + + async function onSubmit(values: z.infer) { + 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 ( +
+
+ + ( + + Name + + + + The name of the commission type. + + + )} + /> + ( + + Description + + + + Optional description. + + + )} + /> + + + + + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/commissions/types/SortableItem.tsx b/src/components/commissions/types/SortableItem.tsx new file mode 100644 index 0000000..c504e8d --- /dev/null +++ b/src/components/commissions/types/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, + 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/commissions/types/SortableItemCard.tsx b/src/components/commissions/types/SortableItemCard.tsx new file mode 100644 index 0000000..92dbca5 --- /dev/null +++ b/src/components/commissions/types/SortableItemCard.tsx @@ -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 ( +
+
+ ☰ +
+ {children} +
+ ) +} diff --git a/src/components/commissions/types/form/ComboboxCreateable.tsx b/src/components/commissions/types/form/ComboboxCreateable.tsx new file mode 100644 index 0000000..ec1f4d8 --- /dev/null +++ b/src/components/commissions/types/form/ComboboxCreateable.tsx @@ -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 + 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 ( + + + + + + + + No results + + {filteredOptions.map((opt) => ( + { + onSelect(opt.value) + setOpen(false) + }} + > + + {opt.label} + + ))} + {showCreate && ( + { + await onCreateNew(input) + setOpen(false) + }} + className="text-primary" + > + + Create “{input}” + + )} + + + + + ) +} diff --git a/src/components/commissions/types/form/CommissionCustomInputField.tsx b/src/components/commissions/types/form/CommissionCustomInputField.tsx new file mode 100644 index 0000000..7c8cb99 --- /dev/null +++ b/src/components/commissions/types/form/CommissionCustomInputField.tsx @@ -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 ( +
+

Custom Inputs

+ + + f.id)} strategy={verticalListSortingStrategy}> + {fields.map((field, index) => { + return ( + +
+ + {/* Picker */} + ( + + Input + + ({ + 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) + }} + /> + + + )} + /> + + {/* Label */} + ( + + Label + + + + + )} + /> + + {/* Input Type */} + ( + + Input Type + + + )} + /> + + {/* Required */} + ( + + Required + + + + + )} + /> + + {/* Remove */} + +
+
+ ) + })} +
+
+ + +
+ ) +} diff --git a/src/components/commissions/types/form/CommissionExtraField.tsx b/src/components/commissions/types/form/CommissionExtraField.tsx new file mode 100644 index 0000000..9b53279 --- /dev/null +++ b/src/components/commissions/types/form/CommissionExtraField.tsx @@ -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 ( +
+

Extras

+ + + 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 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] + + return ( + + Range + + value} + value={[start, end]} + onValueChange={([min, max]) => + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + min={0} + max={100} + step={1} + /> + + + ) + }} + /> + + {/* Remove Button */} + +
+
+ ))} +
+
+ + +
+ ) +} diff --git a/src/components/commissions/types/form/CommissionOptionField.tsx b/src/components/commissions/types/form/CommissionOptionField.tsx new file mode 100644 index 0000000..979b99d --- /dev/null +++ b/src/components/commissions/types/form/CommissionOptionField.tsx @@ -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 ( +
+

Options

+ + + f.id)} strategy={verticalListSortingStrategy}> + + {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 */} + ( + + Price (€) + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> + + {/* Price Percent */} + ( + + + % + + + 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] + + return ( + + Range + + value} + value={[start, end]} + onValueChange={([min, max]) => + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + min={0} + max={100} + step={1} + /> + + + ) + }} + /> + + {/* Remove Button */} + +
+
+ ))} +
+
+ + {/* Add Button */} + +
+ ) +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 60fef72..1560f02 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -103,6 +103,12 @@ export default function TopNav() { + + + Commissions + + + {/* Portfolio diff --git a/src/components/ui/dual-range.tsx b/src/components/ui/dual-range.tsx new file mode 100644 index 0000000..4b49091 --- /dev/null +++ b/src/components/ui/dual-range.tsx @@ -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 { + labelPosition?: 'top' | 'bottom'; + label?: (value: number | undefined) => React.ReactNode; +} + +const DualRangeSlider = React.forwardRef< + React.ElementRef, + DualRangeSliderProps +>(({ className, label, labelPosition = 'top', ...props }, ref) => { + const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max]; + + return ( + + + + + {initialValue.map((value, index) => ( + + + {label && ( + + {label(value)} + + )} + + + ))} + + ); +}); +DualRangeSlider.displayName = 'DualRangeSlider'; + +export { DualRangeSlider }; diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..1a1bd73 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider } diff --git a/src/schemas/commissionType.ts b/src/schemas/commissionType.ts new file mode 100644 index 0000000..81008c1 --- /dev/null +++ b/src/schemas/commissionType.ts @@ -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 '10–80'").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 '10–80'").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 \ No newline at end of file