From 994b33e081c3507cbf88820028b819a4fc4b07a0 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 20:01:49 +0100 Subject: [PATCH] feat(commissions): add customer records and kanban workflow baseline --- TODO.md | 11 +- apps/admin/src/app/commissions/page.tsx | 446 +++++++++++++++++- packages/content/src/commissions.ts | 40 ++ packages/content/src/index.ts | 1 + .../migration.sql | 52 ++ packages/db/prisma/schema.prisma | 37 ++ packages/db/prisma/seed.ts | 48 ++ packages/db/src/commissions.test.ts | 64 +++ packages/db/src/commissions.ts | 66 +++ packages/db/src/index.ts | 8 + 10 files changed, 755 insertions(+), 18 deletions(-) create mode 100644 packages/content/src/commissions.ts create mode 100644 packages/db/prisma/migrations/20260212203000_commissions_customers/migration.sql create mode 100644 packages/db/src/commissions.test.ts create mode 100644 packages/db/src/commissions.ts diff --git a/TODO.md b/TODO.md index bc13ab8..9c9b828 100644 --- a/TODO.md +++ b/TODO.md @@ -124,7 +124,7 @@ This file is the single source of truth for roadmap and delivery progress. S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI - [~] [P1] `todo/mvp1-pages-navigation-builder`: page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) -- [ ] [P1] `todo/mvp1-commissions-customers`: +- [~] [P1] `todo/mvp1-commissions-customers`: commission request intake + admin CRUD + kanban + customer entity/linking - [ ] [P1] `todo/mvp1-announcements-news`: announcement management/rendering + news/blog CRUD and public rendering @@ -156,10 +156,10 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] Users management (invite, roles, status) - [ ] [P1] Disable/ban user function and enforcement in auth/session checks - [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote) -- [ ] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks) -- [ ] [P1] Customer records (contact profile, notes, consent flags, recurrence marker) -- [ ] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers) -- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done) +- [~] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks) +- [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker) +- [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers) +- [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done) - [ ] [P1] Header banner management (message, CTA, active window) - [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting) - [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata) @@ -276,6 +276,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions. - [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`). - [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists). +- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board. ## How We Use This File diff --git a/apps/admin/src/app/commissions/page.tsx b/apps/admin/src/app/commissions/page.tsx index 4826442..539322d 100644 --- a/apps/admin/src/app/commissions/page.tsx +++ b/apps/admin/src/app/commissions/page.tsx @@ -1,34 +1,454 @@ -import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" +import { + commissionKanbanOrder, + createCommission, + createCustomer, + listCommissions, + listCustomers, + updateCommissionStatus, +} from "@cms/db" +import { Button } from "@cms/ui/button" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" + import { AdminShell } from "@/components/admin-shell" import { requirePermissionForRoute } from "@/lib/route-guards" export const dynamic = "force-dynamic" -export default async function CommissionsManagementPage() { +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readNullableString(formData: FormData, field: string): string | null { + const value = readInputString(formData, field) + return value.length > 0 ? value : null +} + +function readNullableNumber(formData: FormData, field: string): number | null { + const value = readInputString(formData, field) + + if (!value) { + return null + } + + const parsed = Number.parseFloat(value) + + if (!Number.isFinite(parsed)) { + return null + } + + return parsed +} + +function readNullableDate(formData: FormData, field: string): Date | null { + const value = readInputString(formData, field) + + if (!value) { + return null + } + + const parsed = new Date(value) + + if (Number.isNaN(parsed.getTime())) { + return null + } + + return parsed +} + +function redirectWithState(params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/commissions?${value}` : "/commissions") +} + +async function createCustomerAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/commissions", + permission: "commissions:write", + scope: "own", + }) + + try { + await createCustomer({ + name: readInputString(formData, "name"), + email: readNullableString(formData, "email"), + phone: readNullableString(formData, "phone"), + instagram: readNullableString(formData, "instagram"), + notes: readNullableString(formData, "notes"), + isRecurring: readInputString(formData, "isRecurring") === "true", + }) + } catch { + redirectWithState({ error: "Failed to create customer." }) + } + + revalidatePath("/commissions") + redirectWithState({ notice: "Customer created." }) +} + +async function createCommissionAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/commissions", + permission: "commissions:write", + scope: "own", + }) + + try { + await createCommission({ + title: readInputString(formData, "title"), + description: readNullableString(formData, "description"), + status: readInputString(formData, "status"), + customerId: readNullableString(formData, "customerId"), + assignedUserId: readNullableString(formData, "assignedUserId"), + budgetMin: readNullableNumber(formData, "budgetMin"), + budgetMax: readNullableNumber(formData, "budgetMax"), + dueAt: readNullableDate(formData, "dueAt"), + }) + } catch { + redirectWithState({ error: "Failed to create commission." }) + } + + revalidatePath("/commissions") + redirectWithState({ notice: "Commission created." }) +} + +async function updateCommissionStatusAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/commissions", + permission: "commissions:transition", + scope: "own", + }) + + try { + await updateCommissionStatus({ + id: readInputString(formData, "id"), + status: readInputString(formData, "status"), + }) + } catch { + redirectWithState({ error: "Failed to transition commission." }) + } + + revalidatePath("/commissions") + redirectWithState({ notice: "Commission status updated." }) +} + +function formatDate(value: Date | null) { + if (!value) { + return "-" + } + + return value.toLocaleDateString("en-US") +} + +export default async function CommissionsManagementPage({ + searchParams, +}: { + searchParams: Promise +}) { const role = await requirePermissionForRoute({ nextPath: "/commissions", permission: "commissions:read", scope: "own", }) + const [resolvedSearchParams, customers, commissions] = await Promise.all([ + searchParams, + listCustomers(200), + listCommissions(300), + ]) + + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + return ( - + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+

Create Customer

+
+ +
+ + +
+ +