Add extras and options CRUD, add sidebar, add kanban board, udpate packages
This commit is contained in:
38
bun.lock
38
bun.lock
@ -5,26 +5,30 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "admin.gaertan.art",
|
"name": "admin.gaertan.art",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.962.0",
|
"@aws-sdk/client-s3": "^3.974.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.962.0",
|
"@aws-sdk/s3-request-presigner": "^3.974.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@platejs/basic-nodes": "^52.0.11",
|
"@platejs/basic-nodes": "^52.0.11",
|
||||||
"@platejs/code-block": "^52.0.11",
|
"@platejs/code-block": "^52.0.11",
|
||||||
"@platejs/indent": "^52.0.11",
|
"@platejs/indent": "^52.0.11",
|
||||||
"@platejs/list": "^52.0.11",
|
"@platejs/list": "^52.0.11",
|
||||||
"@platejs/markdown": "^52.0.11",
|
"@platejs/markdown": "^52.1.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
@ -35,7 +39,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@ -48,19 +52,19 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-vibrant": "^4.0.3",
|
"node-vibrant": "^4.0.3",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.17.2",
|
||||||
"platejs": "^52.0.15",
|
"platejs": "^52.0.17",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.69.0",
|
"react-hook-form": "^7.71.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-scrollbar-hide": "^4.0.0",
|
"tailwind-scrollbar-hide": "^4.0.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.4",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
@ -68,13 +72,13 @@
|
|||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/date-fns": "^2.6.3",
|
"@types/date-fns": "^2.6.3",
|
||||||
"@types/node": "^20.19.27",
|
"@types/node": "^20.19.30",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.5",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"prisma": "^7.2.0",
|
"prisma": "^7.3.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@ -212,6 +216,8 @@
|
|||||||
|
|
||||||
"@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/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/modifiers": ["@dnd-kit/modifiers@9.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-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="],
|
||||||
|
|
||||||
"@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/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=="],
|
"@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=="],
|
||||||
@ -398,6 +404,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "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-presence": "1.1.5", "@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-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "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-presence": "1.1.5", "@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-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "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-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@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" }, "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-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@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-slot": "1.2.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-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@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-slot": "1.2.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-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
@ -438,6 +446,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@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-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "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-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
|
|
||||||
"@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-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-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {
|
"registries": {
|
||||||
"@plate": "https://platejs.org/r/{name}.json"
|
"@plate": "https://platejs.org/r/{name}.json",
|
||||||
|
"@diceui": "https://diceui.com/r/{name}.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ module.exports = {
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
proxyClientMaxBodySize: '50mb',
|
||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.974.0",
|
"@aws-sdk/client-s3": "^3.974.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.974.0",
|
"@aws-sdk/s3-request-presigner": "^3.974.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@platejs/basic-nodes": "^52.0.11",
|
"@platejs/basic-nodes": "^52.0.11",
|
||||||
"@platejs/code-block": "^52.0.11",
|
"@platejs/code-block": "^52.0.11",
|
||||||
@ -24,12 +26,14 @@
|
|||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { COMMISSION_STATUSES } from "@/lib/commissions/kanban";
|
||||||
|
import { prisma } from "@/lib/prisma"; // adjust to your prisma import
|
||||||
|
// import { requireAdmin } from "@/lib/auth/requireAdmin"; // recommended if you have it
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
status: z.enum(COMMISSION_STATUSES),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateCommissionRequestStatus(input: z.infer<typeof schema>) {
|
||||||
|
// await requireAdmin(); // enforce auth/role check here
|
||||||
|
|
||||||
|
const { id, status } = schema.parse(input);
|
||||||
|
|
||||||
|
await prisma.commissionRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
// revalidate the board page so a refresh always reflects server truth
|
||||||
|
revalidatePath("/commissions/board");
|
||||||
|
}
|
||||||
30
src/actions/commissions/types/extras.ts
Normal file
30
src/actions/commissions/types/extras.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { commissionExtraSchema } from "@/schemas/commissionType";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const LIST_PATH = "/commissions/extras";
|
||||||
|
|
||||||
|
export async function createCommissionExtra(input: unknown) {
|
||||||
|
const data = commissionExtraSchema.parse(input);
|
||||||
|
const created = await prisma.commissionExtra.create({ data });
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCommissionExtra(id: string, input: unknown) {
|
||||||
|
const data = commissionExtraSchema.parse(input);
|
||||||
|
const updated = await prisma.commissionExtra.update({ where: { id }, data });
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCommissionExtra(id: string) {
|
||||||
|
// Optional safety:
|
||||||
|
// const used = await prisma.commissionTypeExtra.count({ where: { extraId: id } });
|
||||||
|
// if (used > 0) throw new Error("Extra is linked to types.");
|
||||||
|
console.log("TBD");
|
||||||
|
// await prisma.commissionExtra.delete({ where: { id } });
|
||||||
|
// revalidatePath(LIST_PATH);
|
||||||
|
}
|
||||||
45
src/actions/commissions/types/options.ts
Normal file
45
src/actions/commissions/types/options.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { commissionOptionSchema } from "@/schemas/commissionType";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const LIST_PATH = "/commissions/options";
|
||||||
|
|
||||||
|
function toInt(v: string) {
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCommissionOption(input: unknown) {
|
||||||
|
const data = commissionOptionSchema.parse(input);
|
||||||
|
const created = await prisma.commissionOption.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description?.trim() ? data.description : null,
|
||||||
|
sortIndex: toInt(data.sortIndex),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCommissionOption(id: string, input: unknown) {
|
||||||
|
const data = commissionOptionSchema.parse(input);
|
||||||
|
const updated = await prisma.commissionOption.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description?.trim() ? data.description : null,
|
||||||
|
sortIndex: toInt(data.sortIndex),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCommissionOption(id: string) {
|
||||||
|
console.log("TBD");
|
||||||
|
// await prisma.commissionOption.delete({ where: { id } });
|
||||||
|
// revalidatePath(LIST_PATH);
|
||||||
|
}
|
||||||
47
src/app/(admin)/commissions/kanban/page.tsx
Normal file
47
src/app/(admin)/commissions/kanban/page.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import CommissionsKanbanClient from "@/components/commissions/kanban/CommissionsKanbanClient";
|
||||||
|
import { columnIdForStatus } from "@/lib/commissions/kanban";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
import type { BoardItem, ColumnsState } from "@/types/Board";
|
||||||
|
|
||||||
|
export default async function CommissionsBoardPage() {
|
||||||
|
const requests = await prisma.commissionRequest.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["NEW", "REVIEWING", "ACCEPTED", "INPROGRESS", "COMPLETED"] },
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }],
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
option: true,
|
||||||
|
extras: true,
|
||||||
|
files: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial: ColumnsState = {
|
||||||
|
intake: [],
|
||||||
|
inProgress: [],
|
||||||
|
completed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const r of requests) {
|
||||||
|
const col = columnIdForStatus(r.status) ?? "intake";
|
||||||
|
|
||||||
|
const item: BoardItem = {
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
status: r.status,
|
||||||
|
customerName: r.customerName,
|
||||||
|
customerEmail: r.customerEmail,
|
||||||
|
message: r.message,
|
||||||
|
typeName: r.type?.name ?? null,
|
||||||
|
optionName: r.option?.name ?? null,
|
||||||
|
extrasCount: r.extras.length,
|
||||||
|
filesCount: r.files.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
initial[col].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CommissionsKanbanClient initialColumns={initial} />;
|
||||||
|
}
|
||||||
@ -19,9 +19,9 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
|
|||||||
const extras = await prisma.commissionExtra.findMany({
|
const extras = await prisma.commissionExtra.findMany({
|
||||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
})
|
})
|
||||||
const customInputs = await prisma.commissionCustomInput.findMany({
|
// const customInputs = await prisma.commissionCustomInput.findMany({
|
||||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
// orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
})
|
// })
|
||||||
|
|
||||||
if (!commissionType) {
|
if (!commissionType) {
|
||||||
return <div>Type not found</div>
|
return <div>Type not found</div>
|
||||||
@ -32,7 +32,7 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
|
|||||||
<div className="flex gap-4 justify-between pb-8">
|
<div className="flex gap-4 justify-between pb-8">
|
||||||
<h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1>
|
<h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1>
|
||||||
</div>
|
</div>
|
||||||
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} allCustomInputs={customInputs} />
|
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
10
src/app/(admin)/commissions/types/extras/page.tsx
Normal file
10
src/app/(admin)/commissions/types/extras/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ExtraListClient } from "@/components/commissions/extras/ExtraListClient";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function CommissionTypesExtrasPage() {
|
||||||
|
const extras = await prisma.commissionExtra.findMany({
|
||||||
|
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return <ExtraListClient extras={extras} />;
|
||||||
|
}
|
||||||
10
src/app/(admin)/commissions/types/options/page.tsx
Normal file
10
src/app/(admin)/commissions/types/options/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { OptionsListClient } from "@/components/commissions/options/OptionsListClient";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function CommissionTypesOptionsPage() {
|
||||||
|
const options = await prisma.commissionOption.findMany({
|
||||||
|
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return <OptionsListClient options={options} />;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
import LogoutButton from "@/components/auth/LogoutButton";
|
||||||
import Footer from "@/components/global/Footer";
|
import Footer from "@/components/global/Footer";
|
||||||
import Header from "@/components/global/Header";
|
import MobileSidebar from "@/components/global/MobileSidebar";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import ModeToggle from "@/components/global/ModeToggle";
|
||||||
|
import Sidebar from "@/components/global/Sidebar";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
@ -8,17 +10,48 @@ export default function AdminLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen min-w-screen">
|
// <div className="flex flex-col min-h-screen min-w-screen">
|
||||||
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 py-2">
|
// <header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 py-2">
|
||||||
<Header />
|
// <Header />
|
||||||
|
// </header>
|
||||||
|
// <main className="container mx-auto px-4 py-8">
|
||||||
|
// {children}
|
||||||
|
// </main>
|
||||||
|
// <footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
|
// <Footer />
|
||||||
|
// </footer>
|
||||||
|
// <Toaster />
|
||||||
|
// </div>
|
||||||
|
<div className="min-h-screen w-full">
|
||||||
|
<div className="flex min-h-screen w-full">
|
||||||
|
<aside className="hidden md:flex md:w-64 md:flex-col md:border-r md:bg-background">
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
<div className="flex min-h-screen flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
|
<div className="flex h-14 items-center gap-3 px-4">
|
||||||
|
<div className="md:hidden">
|
||||||
|
<MobileSidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Optional: put breadcrumbs or page title here later */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LogoutButton />
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="flex-1">
|
||||||
{children}
|
<div className="container mx-auto px-4 py-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
<footer className="mt-auto h-14 border-t bg-background/95 px-4 py-2 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||||
<Footer />
|
<Footer />
|
||||||
</footer>
|
</footer>
|
||||||
<Toaster />
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/components/commissions/extras/ExtraDialog.tsx
Normal file
122
src/components/commissions/extras/ExtraDialog.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createCommissionExtra, updateCommissionExtra } from "@/actions/commissions/types/extras";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { type CommissionExtraValues, commissionExtraSchema } from "@/schemas/commissionType";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Initial = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
initial?: Initial;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ExtraDialog({ trigger, mode, initial }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// ✅ key remounts the form per item / create mode, no useEffect needed
|
||||||
|
const formKey = mode === "create" ? "new" : initial?.id ?? "missing";
|
||||||
|
|
||||||
|
const form = useForm<CommissionExtraValues>({
|
||||||
|
resolver: zodResolver(commissionExtraSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: initial?.name ?? "",
|
||||||
|
description: initial?.description ?? ""
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: CommissionExtraValues) {
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
await createCommissionExtra(values);
|
||||||
|
toast.success("Extra created.");
|
||||||
|
} else {
|
||||||
|
if (!initial?.id) throw new Error("Missing extra id");
|
||||||
|
await updateCommissionExtra(initial.id, values);
|
||||||
|
toast.success("Extra updated.");
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to save extra.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "New extra" : "Edit extra"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div key={formKey}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormDescription>Shown to customers.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormDescription>Optional helper text.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/commissions/extras/ExtraListClient.tsx
Normal file
101
src/components/commissions/extras/ExtraListClient.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteCommissionExtra } from "@/actions/commissions/types/extras";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ExtraDialog } from "./ExtraDialog";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ExtraListClient({ extras }: { extras: Item[] }) {
|
||||||
|
const [busyId, setBusyId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
try {
|
||||||
|
setBusyId(id);
|
||||||
|
await deleteCommissionExtra(id);
|
||||||
|
toast.success("Extra deleted.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to delete extra.");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">Extras</h1>
|
||||||
|
|
||||||
|
<ExtraDialog
|
||||||
|
mode="create"
|
||||||
|
trigger={
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New extra
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">All extras</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{extras.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-muted-foreground italic">No extras yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{extras.map((o) => (
|
||||||
|
<li key={o.id} className="flex items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium truncate">{o.name}</div>
|
||||||
|
</div>
|
||||||
|
{o.description ? (
|
||||||
|
<div className="text-sm text-muted-foreground truncate">{o.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExtraDialog
|
||||||
|
mode="edit"
|
||||||
|
initial={o}
|
||||||
|
trigger={
|
||||||
|
<Button variant="secondary" size="sm" className="gap-2">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={busyId === o.id}
|
||||||
|
onClick={() => onDelete(o.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal file
207
src/components/commissions/kanban/CommissionsKanbanClient.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { updateCommissionRequestStatus } from "@/actions/commissions/requests/updateCommissionRequestStatus";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Kanban, KanbanBoard, KanbanColumn, KanbanItem, KanbanOverlay } from "@/components/ui/kanban";
|
||||||
|
import {
|
||||||
|
BOARD_COLUMNS,
|
||||||
|
type BoardColumnId,
|
||||||
|
canonicalStatusForColumn,
|
||||||
|
} from "@/lib/commissions/kanban";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type BoardItem = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
message: string;
|
||||||
|
typeName: string | null;
|
||||||
|
optionName: string | null;
|
||||||
|
extrasCount: number;
|
||||||
|
filesCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColumnsState = Record<BoardColumnId, BoardItem[]>;
|
||||||
|
|
||||||
|
import type { UniqueIdentifier } from "@dnd-kit/core";
|
||||||
|
|
||||||
|
type KanbanValue = Record<UniqueIdentifier, BoardItem[]>;
|
||||||
|
|
||||||
|
function isColumnsState(v: KanbanValue): v is ColumnsState {
|
||||||
|
return (
|
||||||
|
Array.isArray(v.intake) &&
|
||||||
|
Array.isArray(v.inProgress) &&
|
||||||
|
Array.isArray(v.completed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asColumnsState(v: KanbanValue): ColumnsState {
|
||||||
|
if (!isColumnsState(v)) {
|
||||||
|
// Defensive: if something ever changes upstream, keep UI stable
|
||||||
|
return { intake: [], inProgress: [], completed: [] };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findItemColumn(columns: ColumnsState, itemId: string): BoardColumnId | null {
|
||||||
|
for (const col of Object.keys(columns) as BoardColumnId[]) {
|
||||||
|
if (columns[col].some((x) => x.id === itemId)) return col;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffMovedItem(prev: ColumnsState, next: ColumnsState) {
|
||||||
|
const prevLoc = new Map<string, BoardColumnId>();
|
||||||
|
const nextLoc = new Map<string, BoardColumnId>();
|
||||||
|
|
||||||
|
for (const c of Object.keys(prev) as BoardColumnId[]) {
|
||||||
|
for (const i of prev[c]) {
|
||||||
|
prevLoc.set(i.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of Object.keys(next) as BoardColumnId[]) {
|
||||||
|
for (const i of next[c]) {
|
||||||
|
nextLoc.set(i.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, from] of prevLoc.entries()) {
|
||||||
|
const to = nextLoc.get(id);
|
||||||
|
if (to && to !== from) return { id, from, to };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommissionsKanbanClient({
|
||||||
|
initialColumns,
|
||||||
|
}: {
|
||||||
|
initialColumns: ColumnsState;
|
||||||
|
}) {
|
||||||
|
const [columns, setColumns] = React.useState<ColumnsState>(initialColumns);
|
||||||
|
const prevRef = React.useRef(columns);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
prevRef.current = columns;
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
async function persistMove(moved: { id: string; to: BoardColumnId }, snapshotBefore: ColumnsState) {
|
||||||
|
const status = canonicalStatusForColumn(moved.to);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCommissionRequestStatus({ id: moved.id, status });
|
||||||
|
// optional: you could also update the item’s status in local state here
|
||||||
|
// but revalidatePath + eventual refresh will keep it consistent anyway.
|
||||||
|
setColumns((cur) => {
|
||||||
|
const col = findItemColumn(cur, moved.id);
|
||||||
|
if (!col) return cur;
|
||||||
|
return {
|
||||||
|
...cur,
|
||||||
|
[col]: cur[col].map((x) => (x.id === moved.id ? { ...x, status } : x)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Revert optimistic state if update fails
|
||||||
|
setColumns(snapshotBefore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onValueChange(next: KanbanValue) {
|
||||||
|
const nextColumns = asColumnsState(next);
|
||||||
|
const prev = prevRef.current;
|
||||||
|
|
||||||
|
setColumns(nextColumns);
|
||||||
|
|
||||||
|
const moved = diffMovedItem(prev, nextColumns);
|
||||||
|
if (!moved) return;
|
||||||
|
|
||||||
|
void persistMove({ id: moved.id, to: moved.to }, prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Commissions Board</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drag requests between columns to update their status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/commissions/requests">Open list</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Kanban
|
||||||
|
value={columns as unknown as KanbanValue}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
getItemValue={(item) => item.id}
|
||||||
|
>
|
||||||
|
<KanbanBoard className="grid auto-rows-fr gap-3 lg:grid-cols-3">
|
||||||
|
{(Object.keys(columns) as BoardColumnId[]).map((colId) => {
|
||||||
|
const col = BOARD_COLUMNS[colId];
|
||||||
|
const items = columns[colId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KanbanColumn key={colId} value={colId} className="rounded-lg border bg-card/50 p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">{col.title}</span>
|
||||||
|
<Badge variant="secondary" className="rounded-sm">
|
||||||
|
{items.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<KanbanItem key={item.id} value={item.id} asHandle asChild>
|
||||||
|
<div className="rounded-md border bg-background p-3 shadow-xs">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{item.customerName}
|
||||||
|
<span className="text-muted-foreground"> — #{item.id.slice(0, 6)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{item.typeName ?? "No type"}{item.optionName ? ` · ${item.optionName}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="outline" className="rounded-sm text-[11px]">
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{item.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Files: {item.filesCount} · Extras: {item.extrasCount}
|
||||||
|
</span>
|
||||||
|
<Button asChild size="sm" variant="ghost" className="h-7 px-2">
|
||||||
|
<Link href={`/commissions/requests/${item.id}`}>Details</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</KanbanItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</KanbanColumn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</KanbanBoard>
|
||||||
|
|
||||||
|
<KanbanOverlay>
|
||||||
|
<div className="size-full rounded-md bg-primary/10" />
|
||||||
|
</KanbanOverlay>
|
||||||
|
</Kanban>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/commissions/options/OptionDialog.tsx
Normal file
121
src/components/commissions/options/OptionDialog.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createCommissionOption, updateCommissionOption } from "@/actions/commissions/types/options";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { type CommissionOptionValues, commissionOptionSchema } from "@/schemas/commissionType";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Initial = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
initial?: Initial;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OptionDialog({ trigger, mode, initial }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const formKey = mode === "create" ? "new" : initial?.id ?? "missing";
|
||||||
|
|
||||||
|
const form = useForm<CommissionOptionValues>({
|
||||||
|
resolver: zodResolver(commissionOptionSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: initial?.name ?? "",
|
||||||
|
description: initial?.description ?? ""
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: CommissionOptionValues) {
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
await createCommissionOption(values);
|
||||||
|
toast.success("Option created.");
|
||||||
|
} else {
|
||||||
|
if (!initial?.id) throw new Error("Missing option id");
|
||||||
|
await updateCommissionOption(initial.id, values);
|
||||||
|
toast.success("Option updated.");
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to save option.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "New option" : "Edit option"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div key={formKey}>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormDescription>Shown to customers.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl><Input {...field} /></FormControl>
|
||||||
|
<FormDescription>Optional helper text.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/commissions/options/OptionsListClient.tsx
Normal file
101
src/components/commissions/options/OptionsListClient.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteCommissionOption } from "@/actions/commissions/types/options";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { OptionDialog } from "./OptionDialog";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OptionsListClient({ options }: { options: Item[] }) {
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
try {
|
||||||
|
setBusyId(id);
|
||||||
|
await deleteCommissionOption(id);
|
||||||
|
toast.success("Option deleted.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to delete option.");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">Options</h1>
|
||||||
|
|
||||||
|
<OptionDialog
|
||||||
|
mode="create"
|
||||||
|
trigger={
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New option
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">All options</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-muted-foreground italic">No options yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{options.map((o) => (
|
||||||
|
<li key={o.id} className="flex items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium truncate">{o.name}</div>
|
||||||
|
</div>
|
||||||
|
{o.description ? (
|
||||||
|
<div className="text-sm text-muted-foreground truncate">{o.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<OptionDialog
|
||||||
|
mode="edit"
|
||||||
|
initial={o}
|
||||||
|
trigger={
|
||||||
|
<Button variant="secondary" size="sm" className="gap-2">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={busyId === o.id}
|
||||||
|
onClick={() => onDelete(o.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,14 +4,13 @@ import { updateCommissionType } from "@/actions/commissions/types/updateType";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
|
import type { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client";
|
||||||
import { commissionTypeSchema } from "@/schemas/commissionType";
|
import { commissionTypeSchema } from "@/schemas/commissionType";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
import { CommissionCustomInputField } from "./form/CommissionCustomInputField";
|
|
||||||
import { CommissionExtraField } from "./form/CommissionExtraField";
|
import { CommissionExtraField } from "./form/CommissionExtraField";
|
||||||
import { CommissionOptionField } from "./form/CommissionOptionField";
|
import { CommissionOptionField } from "./form/CommissionOptionField";
|
||||||
|
|
||||||
@ -25,10 +24,10 @@ type Props = {
|
|||||||
type: CommissionTypeWithConnections
|
type: CommissionTypeWithConnections
|
||||||
allOptions: CommissionOption[],
|
allOptions: CommissionOption[],
|
||||||
allExtras: CommissionExtra[],
|
allExtras: CommissionExtra[],
|
||||||
allCustomInputs: CommissionCustomInput[]
|
// allCustomInputs: CommissionCustomInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditTypeForm({ type, allOptions, allExtras, allCustomInputs }: Props) {
|
export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<z.infer<typeof commissionTypeSchema>>({
|
const form = useForm<z.infer<typeof commissionTypeSchema>>({
|
||||||
resolver: zodResolver(commissionTypeSchema),
|
resolver: zodResolver(commissionTypeSchema),
|
||||||
@ -103,7 +102,7 @@ export default function EditTypeForm({ type, allOptions, allExtras, allCustomInp
|
|||||||
|
|
||||||
<CommissionOptionField options={allOptions} />
|
<CommissionOptionField options={allOptions} />
|
||||||
<CommissionExtraField extras={allExtras} />
|
<CommissionExtraField extras={allExtras} />
|
||||||
<CommissionCustomInputField customInputs={allCustomInputs} />
|
{/* <CommissionCustomInputField customInputs={allCustomInputs} /> */}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
|
|||||||
33
src/components/global/MobileSidebar.tsx
Normal file
33
src/components/global/MobileSidebar.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
|
export default function MobileSidebar() {
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Open navigation">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
|
||||||
|
<SheetContent side="left" className="p-0 w-80">
|
||||||
|
<SheetHeader className="px-4 py-3">
|
||||||
|
<SheetTitle>Navigation</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<Sidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/global/Sidebar.tsx
Normal file
108
src/components/global/Sidebar.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { adminNav } from "./nav";
|
||||||
|
|
||||||
|
function isActive(pathname: string, href: string) {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Brand / header */}
|
||||||
|
<div className="flex h-14 items-center px-4">
|
||||||
|
<Link href="/" className="text-sm font-semibold tracking-tight">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<nav className="flex flex-col gap-1 p-2">
|
||||||
|
{adminNav.map((entry) => {
|
||||||
|
if (entry.type === "link") {
|
||||||
|
const active = isActive(pathname, entry.href);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={entry.href}
|
||||||
|
asChild
|
||||||
|
variant={active ? "secondary" : "ghost"}
|
||||||
|
className={cn("justify-start")}
|
||||||
|
>
|
||||||
|
<Link href={entry.href}>{entry.title}</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// group
|
||||||
|
const anyChildActive = entry.items.some((i) =>
|
||||||
|
isActive(pathname, i.href)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={entry.title}
|
||||||
|
defaultOpen={anyChildActive}
|
||||||
|
className="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"justify-start",
|
||||||
|
anyChildActive && "font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.title}
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent className="pl-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{entry.items.map((item) => {
|
||||||
|
const active = isActive(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.href}
|
||||||
|
asChild
|
||||||
|
variant={active ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<Link href={item.href}>{item.title}</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Optional: bottom area for version/build info */}
|
||||||
|
<div className="p-3 text-xs text-muted-foreground">
|
||||||
|
{/* e.g. v0.1.0 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/global/nav.ts
Normal file
64
src/components/global/nav.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
export type AdminNavItem = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminNavGroup =
|
||||||
|
| {
|
||||||
|
type: "link";
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "group";
|
||||||
|
title: string;
|
||||||
|
items: AdminNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminNav: AdminNavGroup[] = [
|
||||||
|
{ type: "link", title: "Home", href: "/" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
title: "Upload",
|
||||||
|
items: [
|
||||||
|
{ title: "Single Image", href: "/uploads/single" },
|
||||||
|
{ title: "Multiple Images", href: "/uploads/bulk" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "link", title: "Artworks", href: "/artworks" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
title: "Artwork Management",
|
||||||
|
items: [
|
||||||
|
{ title: "Categories", href: "/categories" },
|
||||||
|
{ title: "Tags", href: "/tags" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
title: "Commissions",
|
||||||
|
items: [
|
||||||
|
{ title: "Requests", href: "/commissions/requests" },
|
||||||
|
{ title: "Board", href: "/commissions/kanban" },
|
||||||
|
{ title: "Types", href: "/commissions/types" },
|
||||||
|
{ title: "TypeOptions", href: "/commissions/types/options" },
|
||||||
|
{ title: "TypeExtras", href: "/commissions/types/extras" },
|
||||||
|
{ title: "Guidelines", href: "/commissions/guidelines" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "link", title: "Terms of Service", href: "/tos" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
title: "Users",
|
||||||
|
items: [
|
||||||
|
{ title: "Users", href: "/users" },
|
||||||
|
{ title: "New User", href: "/users/new" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
1106
src/components/ui/kanban.tsx
Normal file
1106
src/components/ui/kanban.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
44
src/lib/commissions/kanban.ts
Normal file
44
src/lib/commissions/kanban.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export const COMMISSION_STATUSES = [
|
||||||
|
"NEW",
|
||||||
|
"REVIEWING",
|
||||||
|
"ACCEPTED",
|
||||||
|
"REJECTED",
|
||||||
|
"INPROGRESS",
|
||||||
|
"COMPLETED",
|
||||||
|
"SPAM",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CommissionStatus = (typeof COMMISSION_STATUSES)[number];
|
||||||
|
|
||||||
|
export const BOARD_COLUMNS = {
|
||||||
|
intake: {
|
||||||
|
title: "Intake",
|
||||||
|
statuses: ["NEW", "REVIEWING", "ACCEPTED"] as const,
|
||||||
|
// when you drop into this column, we normalize to one canonical status:
|
||||||
|
// NEW should usually be system-created; for manual moves, REVIEWING is safer.
|
||||||
|
canonicalStatus: "REVIEWING" as const,
|
||||||
|
},
|
||||||
|
inProgress: {
|
||||||
|
title: "In Progress",
|
||||||
|
statuses: ["INPROGRESS"] as const,
|
||||||
|
canonicalStatus: "INPROGRESS" as const,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
title: "Completed",
|
||||||
|
statuses: ["COMPLETED"] as const,
|
||||||
|
canonicalStatus: "COMPLETED" as const,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BoardColumnId = keyof typeof BOARD_COLUMNS;
|
||||||
|
|
||||||
|
export function columnIdForStatus(status: string): BoardColumnId | null {
|
||||||
|
if (BOARD_COLUMNS.intake.statuses.includes(status as any)) return "intake";
|
||||||
|
if (BOARD_COLUMNS.inProgress.statuses.includes(status as any)) return "inProgress";
|
||||||
|
if (BOARD_COLUMNS.completed.statuses.includes(status as any)) return "completed";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalStatusForColumn(col: BoardColumnId): CommissionStatus {
|
||||||
|
return BOARD_COLUMNS[col].canonicalStatus as CommissionStatus;
|
||||||
|
}
|
||||||
62
src/lib/compose-refs.ts
Normal file
62
src/lib/compose-refs.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a given ref to a given value
|
||||||
|
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
return ref(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ref !== null && ref !== undefined) {
|
||||||
|
ref.current = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility to compose multiple refs together
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
return (node) => {
|
||||||
|
let hasCleanup = false;
|
||||||
|
const cleanups = refs.map((ref) => {
|
||||||
|
const cleanup = setRef(ref, node);
|
||||||
|
if (!hasCleanup && typeof cleanup === "function") {
|
||||||
|
hasCleanup = true;
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
});
|
||||||
|
|
||||||
|
// React <19 will log an error to the console if a callback ref returns a
|
||||||
|
// value. We don't use ref cleanups internally so this will only happen if a
|
||||||
|
// user's ref callback returns a value, which we only expect if they are
|
||||||
|
// using the cleanup functionality added in React 19.
|
||||||
|
if (hasCleanup) {
|
||||||
|
return () => {
|
||||||
|
for (let i = 0; i < cleanups.length; i++) {
|
||||||
|
const cleanup = cleanups[i];
|
||||||
|
if (typeof cleanup === "function") {
|
||||||
|
cleanup();
|
||||||
|
} else {
|
||||||
|
setRef(refs[i], null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom hook that composes multiple refs
|
||||||
|
* Accepts callback refs and RefObject(s)
|
||||||
|
*/
|
||||||
|
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
|
||||||
|
return React.useCallback(composeRefs(...refs), refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { composeRefs, useComposedRefs };
|
||||||
@ -6,14 +6,20 @@ const optionField = z.object({
|
|||||||
optionId: z.string(),
|
optionId: z.string(),
|
||||||
price: z.number().optional(),
|
price: z.number().optional(),
|
||||||
pricePercent: z.number().optional(),
|
pricePercent: z.number().optional(),
|
||||||
priceRange: z.string().regex(rangePattern, "Format must be like '10–80'").optional(),
|
priceRange: z
|
||||||
|
.string()
|
||||||
|
.regex(rangePattern, "Format must be like '10–80'")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const extraField = z.object({
|
const extraField = z.object({
|
||||||
extraId: z.string(),
|
extraId: z.string(),
|
||||||
price: z.number().optional(),
|
price: z.number().optional(),
|
||||||
pricePercent: z.number().optional(),
|
pricePercent: z.number().optional(),
|
||||||
priceRange: z.string().regex(rangePattern, "Format must be like '10–80'").optional(),
|
priceRange: z
|
||||||
|
.string()
|
||||||
|
.regex(rangePattern, "Format must be like '10–80'")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const customInputsField = z.object({
|
const customInputsField = z.object({
|
||||||
@ -29,6 +35,20 @@ export const commissionTypeSchema = z.object({
|
|||||||
options: z.array(optionField).optional(),
|
options: z.array(optionField).optional(),
|
||||||
extras: z.array(extraField).optional(),
|
extras: z.array(extraField).optional(),
|
||||||
customInputs: z.array(customInputsField).optional(),
|
customInputs: z.array(customInputsField).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>
|
export type commissionTypeSchema = z.infer<typeof commissionTypeSchema>;
|
||||||
|
|
||||||
|
export const commissionOptionSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CommissionOptionValues = z.infer<typeof commissionOptionSchema>;
|
||||||
|
|
||||||
|
export const commissionExtraSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CommissionExtraValues = z.infer<typeof commissionExtraSchema>;
|
||||||
|
|||||||
16
src/types/Board.ts
Normal file
16
src/types/Board.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { BoardColumnId } from "@/lib/commissions/kanban";
|
||||||
|
|
||||||
|
export type BoardItem = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
message: string;
|
||||||
|
typeName: string | null;
|
||||||
|
optionName: string | null;
|
||||||
|
extrasCount: number;
|
||||||
|
filesCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColumnsState = Record<BoardColumnId, BoardItem[]>;
|
||||||
Reference in New Issue
Block a user