feat(commissions): add customer records and kanban workflow baseline
This commit is contained in:
@@ -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<string, string | string[] | undefined>
|
||||
|
||||
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<SearchParamsInput>
|
||||
}) {
|
||||
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 (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/commissions"
|
||||
badge="Admin App"
|
||||
title="Commissions"
|
||||
description="Prepare commissions intake and kanban workflow tooling."
|
||||
description="Manage customers and commission requests with kanban-style status transitions."
|
||||
>
|
||||
<AdminSectionPlaceholder
|
||||
feature="Commissions Workflow"
|
||||
summary="This route is reserved for request intake, ownership assignment, and kanban transitions."
|
||||
requiredPermission="commissions:read (own)"
|
||||
nextSteps={[
|
||||
"Add commissions board with status columns.",
|
||||
"Add assignment, due-date, and notes editing.",
|
||||
"Add transition rules and audit history.",
|
||||
]}
|
||||
/>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Customer</h2>
|
||||
<form action={createCustomerAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Phone</span>
|
||||
<input
|
||||
name="phone"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Instagram</span>
|
||||
<input
|
||||
name="instagram"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Notes</span>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isRecurring" type="checkbox" value="true" className="size-4" />
|
||||
Recurring customer
|
||||
</label>
|
||||
<Button type="submit">Create customer</Button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Commission</h2>
|
||||
<form action={createCommissionAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue="new"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{commissionKanbanOrder.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Customer</span>
|
||||
<select
|
||||
name="customerId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Assigned user id</span>
|
||||
<input
|
||||
name="assignedUserId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Budget min</span>
|
||||
<input
|
||||
name="budgetMin"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Budget max</span>
|
||||
<input
|
||||
name="budgetMax"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Due date</span>
|
||||
<input
|
||||
name="dueAt"
|
||||
type="date"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">Create commission</Button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Kanban Board</h2>
|
||||
<div className="grid gap-3 xl:grid-cols-6">
|
||||
{commissionKanbanOrder.map((status) => {
|
||||
const items = commissions.filter((commission) => commission.status === status)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={status}
|
||||
className="rounded-xl border border-neutral-200 bg-neutral-50 p-3"
|
||||
>
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-700">
|
||||
{status}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{items.length}</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">No commissions</p>
|
||||
) : (
|
||||
items.map((commission) => (
|
||||
<form
|
||||
key={commission.id}
|
||||
action={updateCommissionStatusAction}
|
||||
className="rounded border border-neutral-200 bg-white p-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={commission.id} />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{commission.title}</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{commission.customer?.name ?? "No customer"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Due: {formatDate(commission.dueAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={commission.status}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
{commissionKanbanOrder.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Customers</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Name</th>
|
||||
<th className="py-2 pr-4">Email</th>
|
||||
<th className="py-2 pr-4">Phone</th>
|
||||
<th className="py-2 pr-4">Recurring</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={4}>
|
||||
No customers yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
customers.map((customer) => (
|
||||
<tr key={customer.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{customer.name}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{customer.email ?? "-"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{customer.phone ?? "-"}</td>
|
||||
<td className="py-3 pr-4">{customer.isRecurring ? "yes" : "no"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user