Replaces the hardcoded PROJECT_CODES array with an admin-managed `ProjectCode` model, mirroring the Delivery Locations pattern (PR #100): - ProjectCode model (unique `code` + isActive) + migration seeding the five previously-hardcoded codes; PO.projectCode stays a free-text snapshot (no FK) so history/exports/imports are unchanged. - manage_project_codes permission (Manager + SuperUser + Admin). - /admin/project-codes CRUD screen (table + Add/Edit + activate/delete) and an Administration sidebar link. - ProjectCodeField now takes `options` from the active codes; the three PO forms + pages fetch them from the DB. Static list removed. - Unit test reworked to the options API; CRUD integration test added; documented in App/CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { revalidatePath } from "next/cache";
|
|
import { Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
|
|
const schema = z.object({
|
|
code: z.string().trim().min(1, "Project code is required"),
|
|
});
|
|
|
|
type Result = { ok: true } | { error: string };
|
|
|
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
|
const session = await auth();
|
|
if (!session?.user || !hasPermission(session.user.role, "manage_project_codes")) {
|
|
return { error: "Forbidden" };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function createProjectCode(formData: FormData): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
|
|
|
try {
|
|
await db.projectCode.create({ data: { code: parsed.data.code } });
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
return { error: "That project code already exists." };
|
|
}
|
|
throw e;
|
|
}
|
|
revalidatePath("/admin/project-codes");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function updateProjectCode(id: string, formData: FormData): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
|
|
|
try {
|
|
await db.projectCode.update({ where: { id }, data: { code: parsed.data.code } });
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
return { error: "That project code already exists." };
|
|
}
|
|
throw e;
|
|
}
|
|
revalidatePath("/admin/project-codes");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function toggleProjectCodeActive(id: string): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const code = await db.projectCode.findUnique({ where: { id }, select: { isActive: true } });
|
|
if (!code) return { error: "Not found" };
|
|
await db.projectCode.update({ where: { id }, data: { isActive: !code.isActive } });
|
|
revalidatePath("/admin/project-codes");
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function deleteProjectCode(id: string): Promise<Result> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
// Safe to delete: POs keep their project code as a text snapshot, so no
|
|
// purchase order references this row.
|
|
await db.projectCode.delete({ where: { id } });
|
|
revalidatePath("/admin/project-codes");
|
|
return { ok: true };
|
|
}
|