Files
old.cms.fellies.org/apps/admin/src/app/commissions/page.tsx

455 lines
15 KiB
TypeScript

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"
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="Manage customers and commission requests with kanban-style status transitions."
>
{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>
)
}