Add CommissionTypeForm
This commit is contained in:
130
package-lock.json
generated
130
package-lock.json
generated
@ -9,6 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@prisma/client": "^6.11.1",
|
||||
@ -17,12 +19,15 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
@ -30,6 +35,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.73"
|
||||
},
|
||||
@ -132,6 +138,34 @@
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
@ -1589,6 +1623,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
|
||||
"integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-focus-guards": "1.1.2",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.7",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
||||
@ -1790,6 +1861,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
|
||||
"integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@ -3405,6 +3509,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@ -6876,6 +6996,16 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
|
||||
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
@ -10,6 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@prisma/client": "^6.11.1",
|
||||
@ -18,12 +20,15 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
@ -31,6 +36,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.73"
|
||||
},
|
||||
|
127
prisma/migrations/20250705193450_commision_types/migration.sql
Normal file
127
prisma/migrations/20250705193450_commision_types/migration.sql
Normal file
@ -0,0 +1,127 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `active` on the `CommissionType` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `basePrice` on the `CommissionType` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `deliveryEst` on the `CommissionType` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `tags` on the `CommissionType` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `title` on the `CommissionType` table. All the data in the column will be lost.
|
||||
- You are about to drop the `Artwork` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `CommissionRequest` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Preferences` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `PresentationGroup` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `TOS` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost.
|
||||
- Added the required column `name` to the `CommissionType` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `CommissionType` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Artwork" DROP CONSTRAINT "Artwork_groupId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CommissionRequest" DROP CONSTRAINT "CommissionRequest_typeId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CommissionType" DROP COLUMN "active",
|
||||
DROP COLUMN "basePrice",
|
||||
DROP COLUMN "deliveryEst",
|
||||
DROP COLUMN "tags",
|
||||
DROP COLUMN "title",
|
||||
ADD COLUMN "name" TEXT NOT NULL,
|
||||
ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Artwork";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "CommissionRequest";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Preferences";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "PresentationGroup";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TOS";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "User";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "RequestStatus";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "Role";
|
||||
|
||||
-- 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 "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 "CommissionTypeOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"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 "CommissionExtraOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"typeId" TEXT NOT NULL,
|
||||
"extraId" TEXT NOT NULL,
|
||||
"priceRange" TEXT,
|
||||
"pricePercent" DOUBLE PRECISION,
|
||||
"price" DOUBLE PRECISION,
|
||||
|
||||
CONSTRAINT "CommissionExtraOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommissionTypeOption_typeId_optionId_key" ON "CommissionTypeOption"("typeId", "optionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommissionExtraOption_typeId_extraId_key" ON "CommissionExtraOption"("typeId", "extraId");
|
||||
|
||||
-- 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 "CommissionExtraOption" ADD CONSTRAINT "CommissionExtraOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommissionExtraOption" ADD CONSTRAINT "CommissionExtraOption_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,37 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `CommissionExtraOption` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CommissionExtraOption" DROP CONSTRAINT "CommissionExtraOption_extraId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CommissionExtraOption" DROP CONSTRAINT "CommissionExtraOption_typeId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "CommissionExtraOption";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommissionTypeExtra" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"typeId" TEXT NOT NULL,
|
||||
"extraId" TEXT NOT NULL,
|
||||
"priceRange" TEXT,
|
||||
"pricePercent" DOUBLE PRECISION,
|
||||
"price" DOUBLE PRECISION,
|
||||
|
||||
CONSTRAINT "CommissionTypeExtra_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CommissionTypeExtra_typeId_extraId_key" ON "CommissionTypeExtra"("typeId", "extraId");
|
||||
|
||||
-- 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;
|
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CommissionTypeExtra" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CommissionTypeOption" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
|
@ -14,83 +14,161 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
role Role @default(ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
ARTIST
|
||||
}
|
||||
|
||||
model CommissionType {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
basePrice Float
|
||||
deliveryEst String? // e.g. "2 weeks"
|
||||
tags String[] // e.g. shaded, sketch, full-body
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
CommissionRequest CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionRequest {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
message String
|
||||
typeId String
|
||||
status RequestStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
type CommissionType @relation(fields: [typeId], references: [id])
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
IN_PROGRESS
|
||||
DONE
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model Artwork {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
imageUrl String
|
||||
description String?
|
||||
tags String[]
|
||||
formats String[]
|
||||
isPublic Boolean @default(true)
|
||||
groupId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group PresentationGroup? @relation(fields: [groupId], references: [id])
|
||||
}
|
||||
|
||||
model PresentationGroup {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
Artwork Artwork[]
|
||||
}
|
||||
|
||||
model Preferences {
|
||||
id String @id @default(cuid())
|
||||
commissionOpen Boolean @default(true)
|
||||
defaultDelivery String? // e.g. "7 days"
|
||||
autoReplyMessage String?
|
||||
notifyByEmail Boolean @default(true)
|
||||
}
|
||||
|
||||
model TOS {
|
||||
id String @id @default(cuid())
|
||||
content String // Markdown or rich text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String
|
||||
|
||||
description String?
|
||||
|
||||
options CommissionTypeOption[]
|
||||
extras CommissionTypeExtra[]
|
||||
}
|
||||
|
||||
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 CommissionExtra {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String
|
||||
|
||||
description String?
|
||||
|
||||
types CommissionTypeExtra[]
|
||||
}
|
||||
|
||||
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 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 User {
|
||||
// id String @id @default(cuid())
|
||||
// email String @unique
|
||||
// name String?
|
||||
// role Role @default(ADMIN)
|
||||
// createdAt DateTime @default(now())
|
||||
// }
|
||||
|
||||
// enum Role {
|
||||
// ADMIN
|
||||
// ARTIST
|
||||
// }
|
||||
|
||||
// model CommissionType {
|
||||
// id String @id @default(cuid())
|
||||
// title String
|
||||
// description String?
|
||||
// basePrice Float
|
||||
// deliveryEst String? // e.g. "2 weeks"
|
||||
// tags String[] // e.g. shaded, sketch, full-body
|
||||
// active Boolean @default(true)
|
||||
// createdAt DateTime @default(now())
|
||||
// CommissionRequest CommissionRequest[]
|
||||
// }
|
||||
|
||||
// model CommissionRequest {
|
||||
// id String @id @default(cuid())
|
||||
// name String
|
||||
// email String
|
||||
// message String
|
||||
// typeId String
|
||||
// status RequestStatus @default(PENDING)
|
||||
// createdAt DateTime @default(now())
|
||||
|
||||
// type CommissionType @relation(fields: [typeId], references: [id])
|
||||
// }
|
||||
|
||||
// enum RequestStatus {
|
||||
// PENDING
|
||||
// ACCEPTED
|
||||
// IN_PROGRESS
|
||||
// DONE
|
||||
// REJECTED
|
||||
// }
|
||||
|
||||
// model Artwork {
|
||||
// id String @id @default(cuid())
|
||||
// title String
|
||||
// imageUrl String
|
||||
// description String?
|
||||
// tags String[]
|
||||
// formats String[]
|
||||
// isPublic Boolean @default(true)
|
||||
// groupId String?
|
||||
// createdAt DateTime @default(now())
|
||||
|
||||
// group PresentationGroup? @relation(fields: [groupId], references: [id])
|
||||
// }
|
||||
|
||||
// model PresentationGroup {
|
||||
// id String @id @default(cuid())
|
||||
// name String
|
||||
// description String?
|
||||
// createdAt DateTime @default(now())
|
||||
// Artwork Artwork[]
|
||||
// }
|
||||
|
||||
// model Preferences {
|
||||
// id String @id @default(cuid())
|
||||
// commissionOpen Boolean @default(true)
|
||||
// defaultDelivery String? // e.g. "7 days"
|
||||
// autoReplyMessage String?
|
||||
// notifyByEmail Boolean @default(true)
|
||||
// }
|
||||
|
||||
// model TOS {
|
||||
// id String @id @default(cuid())
|
||||
// content String // Markdown or rich text
|
||||
// createdAt DateTime @default(now())
|
||||
// }
|
||||
|
58
src/actions/items/commissions/types/newType.ts
Normal file
58
src/actions/items/commissions/types/newType.ts
Normal file
@ -0,0 +1,58 @@
|
||||
"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 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) => ({
|
||||
option: { connect: { id: opt.optionId } },
|
||||
price: opt.price,
|
||||
pricePercent: opt.pricePercent,
|
||||
priceRange: opt.priceRange,
|
||||
})) || [],
|
||||
},
|
||||
extras: {
|
||||
create: data.extras?.map((ext) => ({
|
||||
extra: { connect: { id: ext.extraId } },
|
||||
price: ext.price,
|
||||
pricePercent: ext.pricePercent,
|
||||
priceRange: ext.priceRange,
|
||||
})) || [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return created
|
||||
}
|
21
src/app/items/commissions/types/new/page.tsx
Normal file
21
src/app/items/commissions/types/new/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import NewTypeForm from "@/components/items/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" }],
|
||||
})
|
||||
|
||||
|
||||
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} />
|
||||
</div>
|
||||
);
|
||||
}
|
26
src/app/items/commissions/types/page.tsx
Normal file
26
src/app/items/commissions/types/page.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import ListTypes from "@/components/items/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 } },
|
||||
extras: { include: { extra: true } },
|
||||
},
|
||||
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="/items/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>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import Footer from "@/components/global/Footer";
|
||||
import Header from "@/components/global/Header";
|
||||
import { ThemeProvider } from "@/components/global/ThemeProvider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
@ -65,6 +66,7 @@ export default function RootLayout({
|
||||
<Footer />
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
64
src/components/items/commissions/types/ListTypes.tsx
Normal file
64
src/components/items/commissions/types/ListTypes.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma";
|
||||
|
||||
type CommissionTypeWithItems = CommissionType & {
|
||||
options: (CommissionTypeOption & {
|
||||
option: CommissionOption | null
|
||||
})[]
|
||||
extras: (CommissionTypeExtra & {
|
||||
extra: CommissionExtra | null
|
||||
})[]
|
||||
}
|
||||
|
||||
export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{types.map(type => (
|
||||
<Card key={type.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl truncate">{type.name}</CardTitle>
|
||||
<CardDescription>{type.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col justify-start gap-4">
|
||||
<div>
|
||||
<h4 className="font-semibold">Options</h4>
|
||||
<ul className="pl-4 list-disc">
|
||||
{type.options.map((opt) => (
|
||||
<li key={opt.id}>
|
||||
{opt.option?.name}:{" "}
|
||||
{opt.price !== null
|
||||
? `${opt.price}€`
|
||||
: opt.pricePercent
|
||||
? `+${opt.pricePercent}%`
|
||||
: opt.priceRange
|
||||
? `${opt.priceRange}€`
|
||||
: "Included"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">Extras</h4>
|
||||
<ul className="pl-4 list-disc">
|
||||
{type.extras.map((ext) => (
|
||||
<li key={ext.id}>
|
||||
{ext.extra?.name}:{" "}
|
||||
{ext.price !== null
|
||||
? `${ext.price}€`
|
||||
: ext.pricePercent
|
||||
? `+${ext.pricePercent}%`
|
||||
: ext.priceRange
|
||||
? `${ext.priceRange}€`
|
||||
: "Included"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
89
src/components/items/commissions/types/NewTypeForm.tsx
Normal file
89
src/components/items/commissions/types/NewTypeForm.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { createCommissionType } from "@/actions/items/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 { CommissionExtra, CommissionOption } from "@/generated/prisma";
|
||||
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 { CommissionExtraField } from "./form/CommissionExtraField";
|
||||
import { CommissionOptionField } from "./form/CommissionOptionField";
|
||||
|
||||
type Props = {
|
||||
options: CommissionOption[],
|
||||
extras: CommissionExtra[],
|
||||
}
|
||||
|
||||
export default function NewTypeForm({ options, extras }: 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)
|
||||
router.push("/items/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} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
@ -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-[300px] 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>
|
||||
)
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import { createCommissionExtra } from "@/actions/items/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"
|
||||
import { useState } from "react"
|
||||
import { useFieldArray, useFormContext } from "react-hook-form"
|
||||
import { ComboboxCreateable } from "./ComboboxCreateable"
|
||||
|
||||
type Props = {
|
||||
extras: CommissionExtra[]
|
||||
}
|
||||
|
||||
export function CommissionExtraField({ extras: initialExtras }: Props) {
|
||||
const { control } = useFormContext()
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "extras",
|
||||
})
|
||||
|
||||
const [extras, setExtras] = useState(initialExtras)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Extras</h3>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-5 gap-4 items-end">
|
||||
{/* Extra Picker (combobox with create) */}
|
||||
<FormField
|
||||
control={control}
|
||||
name={`extras.${index}.extraId`}
|
||||
render={({ field: extraField }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Extra</FormLabel>
|
||||
<FormControl>
|
||||
<ComboboxCreateable
|
||||
options={extras.map((e) => ({
|
||||
label: e.name,
|
||||
value: e.id,
|
||||
}))}
|
||||
selected={extraField.value}
|
||||
onSelect={extraField.onChange}
|
||||
onCreateNew={async (name) => {
|
||||
const newExtra = await createCommissionExtra({ name })
|
||||
setExtras((prev) => [...prev, newExtra])
|
||||
extraField.onChange(newExtra.id)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
value={[start, end]}
|
||||
onChange={([min, max]) =>
|
||||
field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Remove Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
append({
|
||||
extraId: "",
|
||||
price: undefined,
|
||||
pricePercent: undefined,
|
||||
priceRange: "0–0",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Extra
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import { createCommissionOption } from "@/actions/items/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"
|
||||
import { useState } from "react"
|
||||
import { useFieldArray, useFormContext } from "react-hook-form"
|
||||
import { ComboboxCreateable } from "./ComboboxCreateable"
|
||||
|
||||
type Props = {
|
||||
options: CommissionOption[]
|
||||
}
|
||||
|
||||
export function CommissionOptionField({ options: initialOptions }: Props) {
|
||||
const { control } = useFormContext()
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "options",
|
||||
})
|
||||
|
||||
const [options, setOptions] = useState(initialOptions)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Options</h3>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-5 gap-4 items-end">
|
||||
{/* Option Picker (combobox with create) */}
|
||||
<FormField
|
||||
control={control}
|
||||
name={`options.${index}.optionId`}
|
||||
render={({ field: optionField }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Option</FormLabel>
|
||||
<FormControl>
|
||||
<ComboboxCreateable
|
||||
options={options.map((o) => ({
|
||||
label: o.name,
|
||||
value: o.id,
|
||||
}))}
|
||||
selected={optionField.value}
|
||||
onSelect={optionField.onChange}
|
||||
onCreateNew={async (name) => {
|
||||
const newOption = await createCommissionOption({ name })
|
||||
setOptions((prev) => [...prev, newOption])
|
||||
optionField.onChange(newOption.id)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
value={[start, end]}
|
||||
onChange={([min, max]) =>
|
||||
field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Remove Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
append({
|
||||
optionId: "",
|
||||
price: undefined,
|
||||
pricePercent: undefined,
|
||||
priceRange: "0–0",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Option
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
39
src/components/ui/dual-range.tsx
Normal file
39
src/components/ui/dual-range.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
type DualRangeProps = {
|
||||
value: [number, number];
|
||||
onChange: (val: [number, number]) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
};
|
||||
|
||||
export function DualRangeSlider({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 200,
|
||||
step = 1,
|
||||
}: DualRangeProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm mb-1">Range: {value[0]}–{value[1]}</div>
|
||||
<SliderPrimitive.Root
|
||||
className="relative flex w-full touch-none select-none items-center"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange([val[0], val[1]])}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-muted">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full bg-white border border-primary shadow transition-colors focus:outline-none" />
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full bg-white border border-primary shadow transition-colors focus:outline-none" />
|
||||
</SliderPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
14
src/lib/prisma.ts
Normal file
14
src/lib/prisma.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// import { PrismaClient } from '@/types/prisma'
|
||||
// import { withAccelerate } from '@prisma/extension-accelerate'
|
||||
|
||||
import { PrismaClient } from "@/generated/prisma"
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient
|
||||
}
|
||||
|
||||
const prisma = globalForPrisma.prisma || new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
export default prisma
|
26
src/schemas/commissionType.ts
Normal file
26
src/schemas/commissionType.ts
Normal file
@ -0,0 +1,26 @@
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>
|
Reference in New Issue
Block a user