From 4ed27d668b61f510f2e4a1dd69af8968d946db1b Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Wed, 24 Jun 2026 13:35:59 +0530 Subject: [PATCH 1/2] feat(po): Project Code dropdown on PO forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the free-text Project Code input with a native +
diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx index 9d4a67f..27bb851 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -9,6 +9,7 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; +import { ProjectCodeField } from "@/components/po/project-code-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; @@ -197,7 +198,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
- +
diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index 782275f..30aca25 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -9,6 +9,7 @@ import { FileUploader } from "@/components/po/file-uploader"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { VendorSelect } from "@/components/ui/vendor-select"; import { DeliveryLocationField } from "@/components/po/delivery-location-field"; +import { ProjectCodeField } from "@/components/po/project-code-field"; import { PoTermsEditor } from "@/components/po/po-terms-editor"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import type { CatalogueCategory, PoTerm } from "@/lib/terms"; @@ -161,7 +162,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
- +
diff --git a/App/components/po/project-code-field.tsx b/App/components/po/project-code-field.tsx new file mode 100644 index 0000000..568617a --- /dev/null +++ b/App/components/po/project-code-field.tsx @@ -0,0 +1,34 @@ +/** + * Project Code dropdown (issue #124) — a native + + {currentMissing && } + {PROJECT_CODES.map((code) => ( + + ))} + + ); +} diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts index 7be1042..312482c 100644 --- a/App/lib/validations/po.ts +++ b/App/lib/validations/po.ts @@ -18,6 +18,20 @@ export const TC_FIXED_LINE = export const TC_FIXED_LINE_2 = "We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material."; +/** + * Fixed list of selectable Project Codes (issue #124). The PO `projectCode` + * column stays a nullable free-text snapshot — this list only constrains how + * the value is picked in the three PO forms (so legacy / imported values are + * never rejected). Extend here to add a code everywhere at once. + */ +export const PROJECT_CODES = [ + "Petronet LNG Cochin", + "COMACOE Trombay", + "Haldia Reach", + "Haldia MMT", + "COMACOE Mandvi", +] as const; + export const TC_DEFAULTS = { tcDelivery: "Within 4 to 5 days", tcDispatch: "To be transported to site address as above. Freight Supplier's A/C", diff --git a/App/tests/unit/project-code-field.test.tsx b/App/tests/unit/project-code-field.test.tsx new file mode 100644 index 0000000..635e255 --- /dev/null +++ b/App/tests/unit/project-code-field.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProjectCodeField } from "@/components/po/project-code-field"; +import { PROJECT_CODES } from "@/lib/validations/po"; + +function options(container: HTMLElement) { + return Array.from(container.querySelectorAll("option")).map((o) => ({ + value: o.getAttribute("value"), + text: o.textContent, + })); +} + +describe("ProjectCodeField", () => { + it("renders a select named projectCode with an empty option + every fixed code", () => { + const { container } = render(); + const select = container.querySelector("select"); + expect(select?.getAttribute("name")).toBe("projectCode"); + + const opts = options(container); + // empty "none" option first, then exactly the fixed codes + expect(opts[0].value).toBe(""); + expect(opts.slice(1).map((o) => o.value)).toEqual([...PROJECT_CODES]); + }); + + it("selects a current value that is one of the fixed codes (no duplicate option)", () => { + const { container } = render(); + const select = container.querySelector("select") as HTMLSelectElement; + expect(select.value).toBe("Haldia Reach"); + // only the fixed codes + empty option — no extra "(current)" entry + expect(container.querySelectorAll("option")).toHaveLength(PROJECT_CODES.length + 1); + }); + + it("preserves a legacy current value not in the list as a leading (current) option", () => { + const { container } = render(); + const select = container.querySelector("select") as HTMLSelectElement; + expect(select.value).toBe("Legacy Project X"); + expect(screen.getByText("Legacy Project X (current)")).toBeInTheDocument(); + // empty + (current) + fixed codes + expect(container.querySelectorAll("option")).toHaveLength(PROJECT_CODES.length + 2); + }); + + it("defaults to the empty option when no current value is given", () => { + const { container } = render(); + const select = container.querySelector("select") as HTMLSelectElement; + expect(select.value).toBe(""); + }); +}); -- 2.45.3 From 02c0806d359f26bfc7f5abbfe8e74adbf5045583 Mon Sep 17 00:00:00 2001 From: Hardik Date: Fri, 26 Jun 2026 02:51:52 +0530 Subject: [PATCH 2/2] refactor(po): admin-managed Project Codes instead of a static list 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 --- App/CLAUDE.md | 6 + .../(portal)/admin/project-codes/actions.ts | 82 +++++++++++ App/app/(portal)/admin/project-codes/page.tsx | 28 ++++ .../admin/project-codes/project-code-form.tsx | 96 +++++++++++++ .../project-codes/project-codes-table.tsx | 131 ++++++++++++++++++ .../approvals/[id]/manager-edit-po-form.tsx | 5 +- App/app/(portal)/approvals/[id]/page.tsx | 5 +- .../(portal)/po/[id]/edit/edit-po-form.tsx | 5 +- App/app/(portal)/po/[id]/edit/page.tsx | 5 +- App/app/(portal)/po/new/new-po-form.tsx | 10 +- App/app/(portal)/po/new/page.tsx | 5 +- App/components/layout/sidebar.tsx | 2 + App/components/po/project-code-field.tsx | 13 +- App/lib/permissions.ts | 4 + App/lib/validations/po.ts | 14 -- .../migration.sql | 22 +++ App/prisma/schema.prisma | 13 ++ App/tests/integration/project-codes.test.ts | 81 +++++++++++ App/tests/unit/project-code-field.test.tsx | 34 +++-- 19 files changed, 519 insertions(+), 42 deletions(-) create mode 100644 App/app/(portal)/admin/project-codes/actions.ts create mode 100644 App/app/(portal)/admin/project-codes/page.tsx create mode 100644 App/app/(portal)/admin/project-codes/project-code-form.tsx create mode 100644 App/app/(portal)/admin/project-codes/project-codes-table.tsx create mode 100644 App/prisma/migrations/20260626120000_project_codes/migration.sql create mode 100644 App/tests/integration/project-codes.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 7a40344..156c1b9 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -104,6 +104,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `` — a native `` populated from the **active** codes plus an empty "— none —" option (the field stays **optional**). **`PurchaseOrder.projectCode` stays a nullable free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a code is therefore always safe (no PO references it). + ### Terms & Conditions catalogue (issue #11) Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**. diff --git a/App/app/(portal)/admin/project-codes/actions.ts b/App/app/(portal)/admin/project-codes/actions.ts new file mode 100644 index 0000000..bf0435d --- /dev/null +++ b/App/app/(portal)/admin/project-codes/actions.ts @@ -0,0 +1,82 @@ +"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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/admin/project-codes/page.tsx b/App/app/(portal)/admin/project-codes/page.tsx new file mode 100644 index 0000000..10cd700 --- /dev/null +++ b/App/app/(portal)/admin/project-codes/page.tsx @@ -0,0 +1,28 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import { ProjectCodesTable } from "./project-codes-table"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Project Codes" }; + +export default async function ProjectCodesPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_project_codes")) redirect("/dashboard"); + + const projectCodes = await db.projectCode.findMany({ + orderBy: [{ isActive: "desc" }, { code: "asc" }], + }); + + return ( + ({ + id: c.id, + code: c.code, + isActive: c.isActive, + }))} + /> + ); +} diff --git a/App/app/(portal)/admin/project-codes/project-code-form.tsx b/App/app/(portal)/admin/project-codes/project-code-form.tsx new file mode 100644 index 0000000..34b0b4a --- /dev/null +++ b/App/app/(portal)/admin/project-codes/project-code-form.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { createProjectCode, updateProjectCode } from "./actions"; + +const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; + +export type ProjectCodeRow = { + id: string; + code: string; + isActive: boolean; +}; + +function Fields({ projectCode }: { projectCode?: ProjectCodeRow }) { + return ( +
+
+ + +
+
+ ); +} + +export function AddProjectCodeButton() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); setPending(true); setError(""); + const result = await createProjectCode(new FormData(e.currentTarget)); + if ("error" in result) { setError(result.error); setPending(false); } + else { setPending(false); setOpen(false); router.refresh(); } + } + + return ( + <> + + setOpen(false)} title="Add Project Code"> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +export function EditProjectCodeButton({ + projectCode, + open: controlledOpen, + onOpenChange, +}: { + projectCode: ProjectCodeRow; + open?: boolean; + onOpenChange?: (v: boolean) => void; +}) { + const router = useRouter(); + const [internalOpen, setInternalOpen] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); setPending(true); setError(""); + const result = await updateProjectCode(projectCode.id, new FormData(e.currentTarget)); + if ("error" in result) { setError(result.error); setPending(false); } + else { setPending(false); setOpen(false); router.refresh(); } + } + + return ( + setOpen(false)} title="Edit Project Code"> +
+ + {error &&

{error}

} +
+ + +
+ +
+ ); +} diff --git a/App/app/(portal)/admin/project-codes/project-codes-table.tsx b/App/app/(portal)/admin/project-codes/project-codes-table.tsx new file mode 100644 index 0000000..27954b1 --- /dev/null +++ b/App/app/(portal)/admin/project-codes/project-codes-table.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { useTableControls } from "@/components/ui/use-table-controls"; +import { TableControls, SortableTh } from "@/components/ui/table-controls"; +import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; +import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + AddProjectCodeButton, + EditProjectCodeButton, + type ProjectCodeRow, +} from "./project-code-form"; +import { deleteProjectCode, toggleProjectCodeActive } from "./actions"; + +const CHIPS = ["Active", "Inactive"]; + +function ProjectCodeActionsMenu({ projectCode }: { projectCode: ProjectCodeRow }) { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [toggleOpen, setToggleOpen] = useState(false); + + return ( + <> + + setEditOpen(true)}>Edit + setToggleOpen(true)}> + {projectCode.isActive ? "Deactivate" : "Activate"} + + + setDeleteOpen(true)}>Delete + + + + deleteProjectCode(projectCode.id)} + /> + toggleProjectCodeActive(projectCode.id)} + /> + + ); +} + +export function ProjectCodesTable({ projectCodes }: { projectCodes: ProjectCodeRow[] }) { + const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } = + useTableControls({ + rows: projectCodes, + defaultSortKey: "code", + searchText: (c) => [c.code, c.isActive ? "active" : "inactive"].join(" "), + chipMatch: (c, chip) => { + if (chip.toLowerCase() === "active") return c.isActive; + if (chip.toLowerCase() === "inactive") return !c.isActive; + return false; + }, + sortValue: (c, key) => { + if (key === "isActive") return c.isActive ? "Active" : "Inactive"; + const val = c[key as keyof ProjectCodeRow]; + return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? ""); + }, + }); + + return ( +
+
+
+

Project Codes

+

Codes that populate the PO “Project Code” dropdown

+
+ +
+ + + +
+ + + + toggleSort(k as keyof ProjectCodeRow)}>Project Code + toggleSort(k as keyof ProjectCodeRow)}>Status + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((projectCode) => ( + + + + + + ))} + +
+ No project codes yet. Add one to populate the Project Code dropdown. +
{projectCode.code} + + {projectCode.isActive ? "Active" : "Inactive"} + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx b/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx index 749f6ac..f00ff0f 100644 --- a/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx +++ b/App/app/(portal)/approvals/[id]/manager-edit-po-form.tsx @@ -44,6 +44,7 @@ interface Props { vendors: Vendor[]; companies: CompanyOption[]; deliveryOptions: string[]; + projectCodeOptions: string[]; termsCatalogue: CatalogueCategory[]; initialTerms: PoTerm[]; } @@ -58,7 +59,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco return ; } -export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) { +export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms }: Props) { const router = useRouter(); const [editing, setEditing] = useState(false); const [pending, setPending] = useState(false); @@ -196,7 +197,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
- +
diff --git a/App/app/(portal)/approvals/[id]/page.tsx b/App/app/(portal)/approvals/[id]/page.tsx index f2094a4..cde43d3 100644 --- a/App/app/(portal)/approvals/[id]/page.tsx +++ b/App/app/(portal)/approvals/[id]/page.tsx @@ -32,7 +32,7 @@ export default async function ApprovalDetailPage({ params }: Props) { }); const hasSignature = !!(currentUser?.signatureKey); - const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([ + const [po, vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([ db.purchaseOrder.findUnique({ where: { id }, include: { @@ -56,6 +56,7 @@ export default async function ApprovalDetailPage({ params }: Props) { db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }), + db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }), ]); if (!po) notFound(); @@ -63,6 +64,7 @@ export default async function ApprovalDetailPage({ params }: Props) { const accounts = buildAccountGroups(leafAccounts); const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); + const projectCodeOptions = projectCodes.map((c) => c.code); const termsCatalogue = await getTermsCatalogue(); const savedTerms = parsePoTerms(po.terms); const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po); @@ -107,6 +109,7 @@ export default async function ApprovalDetailPage({ params }: Props) { vendors={vendors} companies={companies} deliveryOptions={deliveryOptions} + projectCodeOptions={projectCodeOptions} termsCatalogue={termsCatalogue} initialTerms={initialTerms} /> diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx index 27bb851..a10562d 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -46,12 +46,13 @@ interface Props { vendors: Vendor[]; companies: CompanyOption[]; deliveryOptions: string[]; + projectCodeOptions: string[]; termsCatalogue: CatalogueCategory[]; initialTerms: PoTerm[]; managerNoteAuthor?: string | null; } -export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) { +export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) { const router = useRouter(); const [lineItems, setLineItems] = useState( po.lineItems.map((li) => ({ @@ -198,7 +199,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
- +
diff --git a/App/app/(portal)/po/[id]/edit/page.tsx b/App/app/(portal)/po/[id]/edit/page.tsx index 7c44168..dc1d988 100644 --- a/App/app/(portal)/po/[id]/edit/page.tsx +++ b/App/app/(portal)/po/[id]/edit/page.tsx @@ -32,7 +32,7 @@ export default async function EditPoPage({ params }: Props) { const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER"; if (!canEdit) redirect(`/po/${id}`); - const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([ + const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes, noteAction] = await Promise.all([ db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.account.findMany({ where: { isActive: true, children: { none: {} } }, @@ -42,6 +42,7 @@ export default async function EditPoPage({ params }: Props) { db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }), + db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }), po.status === "EDITS_REQUESTED" ? db.pOAction.findFirst({ where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } }, @@ -53,6 +54,7 @@ export default async function EditPoPage({ params }: Props) { const accounts = buildAccountGroups(leafAccounts); const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); + const projectCodeOptions = projectCodes.map((c) => c.code); const termsCatalogue = await getTermsCatalogue(); const savedTerms = parsePoTerms(po.terms); const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po); @@ -82,6 +84,7 @@ export default async function EditPoPage({ params }: Props) { vendors={vendors} companies={companies} deliveryOptions={deliveryOptions} + projectCodeOptions={projectCodeOptions} termsCatalogue={termsCatalogue} initialTerms={initialTerms} managerNoteAuthor={noteAction?.actor.name ?? null} diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index 30aca25..2896ec9 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -31,6 +31,7 @@ interface Props { vendors: Vendor[]; companies: CompanyOption[]; deliveryOptions: string[]; + projectCodeOptions: string[]; termsCatalogue: CatalogueCategory[]; defaultTerms: PoTerm[]; initialLineItems?: LineItemInput[]; @@ -39,7 +40,7 @@ interface Props { initialCompanyId?: string; } -export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) { +export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) { const router = useRouter(); const [lineItems, setLineItems] = useState( initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] @@ -162,7 +163,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
- + + {projectCodeOptions.length === 0 && ( +

+ No project codes configured yet — a Manager can add them under Administration → Project Codes. +

+ )}
diff --git a/App/app/(portal)/po/new/page.tsx b/App/app/(portal)/po/new/page.tsx index e81fe95..8d8c55d 100644 --- a/App/app/(portal)/po/new/page.tsx +++ b/App/app/(portal)/po/new/page.tsx @@ -48,7 +48,7 @@ export default async function NewPoPage({ searchParams }: Props) { } } - const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([ + const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([ db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.account.findMany({ where: { isActive: true, children: { none: {} } }, @@ -58,10 +58,12 @@ export default async function NewPoPage({ searchParams }: Props) { db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }), + db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }), ]); const accounts = buildAccountGroups(leafAccounts); const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); + const projectCodeOptions = projectCodes.map((c) => c.code); const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]); return ( @@ -78,6 +80,7 @@ export default async function NewPoPage({ searchParams }: Props) { vendors={vendors} companies={companies} deliveryOptions={deliveryOptions} + projectCodeOptions={projectCodeOptions} termsCatalogue={termsCatalogue} defaultTerms={defaultTerms} initialLineItems={initialLineItems} diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index b9ef796..436cba2 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -35,6 +35,7 @@ import { Gauge, BadgeCheck, Truck, + FolderKanban, ScrollText, ChevronRight, } from "lucide-react"; @@ -123,6 +124,7 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [ { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] }, { href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] }, + { href: "/admin/project-codes", label: "Project Codes", icon: FolderKanban, roles: ["MANAGER", "SUPERUSER", "ADMIN"] }, { href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] }, // Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN). ...(CREWING_ENABLED diff --git a/App/components/po/project-code-field.tsx b/App/components/po/project-code-field.tsx index 568617a..2665b88 100644 --- a/App/components/po/project-code-field.tsx +++ b/App/components/po/project-code-field.tsx @@ -1,30 +1,31 @@ /** * Project Code dropdown (issue #124) — a native {currentMissing && } - {PROJECT_CODES.map((code) => ( + {options.map((code) => ( diff --git a/App/lib/permissions.ts b/App/lib/permissions.ts index 8589e79..bba6b6b 100644 --- a/App/lib/permissions.ts +++ b/App/lib/permissions.ts @@ -23,6 +23,7 @@ export type Permission = | "manage_products" | "manage_sites" | "manage_delivery_locations" + | "manage_project_codes" | "manage_terms" // ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ────── | "raise_requisition" @@ -84,6 +85,7 @@ const PO_ROLE_PERMISSIONS: Record = { "manage_products", "manage_sites", "manage_delivery_locations", + "manage_project_codes", "manage_terms", "confirm_receipt", "process_payment" @@ -105,6 +107,7 @@ const PO_ROLE_PERMISSIONS: Record = { "export_reports", "create_vendor", "manage_delivery_locations", + "manage_project_codes", "manage_terms", ], AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"], @@ -120,6 +123,7 @@ const PO_ROLE_PERMISSIONS: Record = { "manage_products", "manage_sites", "manage_delivery_locations", + "manage_project_codes", "manage_terms", ], SITE_STAFF: [], diff --git a/App/lib/validations/po.ts b/App/lib/validations/po.ts index 312482c..7be1042 100644 --- a/App/lib/validations/po.ts +++ b/App/lib/validations/po.ts @@ -18,20 +18,6 @@ export const TC_FIXED_LINE = export const TC_FIXED_LINE_2 = "We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material."; -/** - * Fixed list of selectable Project Codes (issue #124). The PO `projectCode` - * column stays a nullable free-text snapshot — this list only constrains how - * the value is picked in the three PO forms (so legacy / imported values are - * never rejected). Extend here to add a code everywhere at once. - */ -export const PROJECT_CODES = [ - "Petronet LNG Cochin", - "COMACOE Trombay", - "Haldia Reach", - "Haldia MMT", - "COMACOE Mandvi", -] as const; - export const TC_DEFAULTS = { tcDelivery: "Within 4 to 5 days", tcDispatch: "To be transported to site address as above. Freight Supplier's A/C", diff --git a/App/prisma/migrations/20260626120000_project_codes/migration.sql b/App/prisma/migrations/20260626120000_project_codes/migration.sql new file mode 100644 index 0000000..3b13301 --- /dev/null +++ b/App/prisma/migrations/20260626120000_project_codes/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "ProjectCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectCode_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectCode_code_key" ON "ProjectCode"("code"); + +-- Seed the previously-hardcoded project codes so the dropdown stays populated +-- after this feature replaces the static list (issue #124). +INSERT INTO "ProjectCode" ("id", "code", "updatedAt") VALUES + ('pcseed_petronet', 'Petronet LNG Cochin', CURRENT_TIMESTAMP), + ('pcseed_comacoe_trombay', 'COMACOE Trombay', CURRENT_TIMESTAMP), + ('pcseed_haldia_reach', 'Haldia Reach', CURRENT_TIMESTAMP), + ('pcseed_haldia_mmt', 'Haldia MMT', CURRENT_TIMESTAMP), + ('pcseed_comacoe_mandvi', 'COMACOE Mandvi', CURRENT_TIMESTAMP); diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index e8aa45e..13759d1 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -410,6 +410,19 @@ model DeliveryLocation { @@index([companyId]) } +// Admin-managed project codes (issue #124). An admin-curated list of project +// codes that backs the PO "Project Code" dropdown (previously a hardcoded list). +// The PO stores the chosen text snapshot in PurchaseOrder.projectCode (nullable, +// point-in-time document), so editing/removing a code never rewrites historical +// POs. Managed by manage_project_codes. +model ProjectCode { + id String @id @default(cuid()) + code String @unique + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // Admin-managed Terms & Conditions catalogue (issue #11). Categories are // user-defined data (not a fixed set) — admins add new ones — and every PO T&C // line is a catalogued clause, including the standard "fixed" lines (seeded under diff --git a/App/tests/integration/project-codes.test.ts b/App/tests/integration/project-codes.test.ts new file mode 100644 index 0000000..205b18a --- /dev/null +++ b/App/tests/integration/project-codes.test.ts @@ -0,0 +1,81 @@ +/** + * Integration tests for the Project Codes admin CRUD (issue #124). + * Covers create/update/toggle/delete + the manage_project_codes guard. + */ +import { vi, describe, it, expect, afterAll } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + createProjectCode, + updateProjectCode, + toggleProjectCodeActive, + deleteProjectCode, +} from "@/app/(portal)/admin/project-codes/actions"; +import { makeSession, fd } from "./helpers"; + +const mockedAuth = vi.mocked(auth); +const PREFIX = "INTTEST_PROJCODE_"; + +const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never); + +afterAll(async () => { + await db.projectCode.deleteMany({ where: { code: { startsWith: PREFIX } } }); +}); + +describe("createProjectCode", () => { + it("persists an active project code", async () => { + asManager(); + const result = await createProjectCode(fd({ code: `${PREFIX}Alpha` })); + expect(result).toEqual({ ok: true }); + + const code = await db.projectCode.findFirstOrThrow({ where: { code: `${PREFIX}Alpha` } }); + expect(code.isActive).toBe(true); + }); + + it("requires a non-empty code", async () => { + asManager(); + expect("error" in (await createProjectCode(fd({ code: " " })))).toBe(true); + }); + + it("rejects a duplicate code", async () => { + asManager(); + await createProjectCode(fd({ code: `${PREFIX}Dup` })); + const result = await createProjectCode(fd({ code: `${PREFIX}Dup` })); + expect("error" in result).toBe(true); + }); + + it("refuses callers without manage_project_codes", async () => { + mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never); + expect(await createProjectCode(fd({ code: `${PREFIX}X` }))).toEqual({ error: "Forbidden" }); + mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never); + expect(await createProjectCode(fd({ code: `${PREFIX}X` }))).toEqual({ error: "Forbidden" }); + }); +}); + +describe("updateProjectCode / toggle / delete", () => { + it("edits, toggles active, then deletes a project code", async () => { + asManager(); + await createProjectCode(fd({ code: `${PREFIX}Old` })); + const code = await db.projectCode.findFirstOrThrow({ where: { code: `${PREFIX}Old` } }); + + expect(await updateProjectCode(code.id, fd({ code: `${PREFIX}New` }))).toEqual({ ok: true }); + expect((await db.projectCode.findUniqueOrThrow({ where: { id: code.id } })).code).toBe(`${PREFIX}New`); + + expect(await toggleProjectCodeActive(code.id)).toEqual({ ok: true }); + expect((await db.projectCode.findUniqueOrThrow({ where: { id: code.id } })).isActive).toBe(false); + + expect(await deleteProjectCode(code.id)).toEqual({ ok: true }); + expect(await db.projectCode.findUnique({ where: { id: code.id } })).toBeNull(); + }); + + it("guards update/toggle/delete behind the permission", async () => { + mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never); + expect(await updateProjectCode("x", fd({ code: "y" }))).toEqual({ error: "Forbidden" }); + expect(await toggleProjectCodeActive("x")).toEqual({ error: "Forbidden" }); + expect(await deleteProjectCode("x")).toEqual({ error: "Forbidden" }); + }); +}); diff --git a/App/tests/unit/project-code-field.test.tsx b/App/tests/unit/project-code-field.test.tsx index 635e255..136bb61 100644 --- a/App/tests/unit/project-code-field.test.tsx +++ b/App/tests/unit/project-code-field.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { ProjectCodeField } from "@/components/po/project-code-field"; -import { PROJECT_CODES } from "@/lib/validations/po"; + +const OPTIONS = ["Petronet LNG Cochin", "Haldia Reach", "COMACOE Mandvi"]; function options(container: HTMLElement) { return Array.from(container.querySelectorAll("option")).map((o) => ({ @@ -11,37 +12,44 @@ function options(container: HTMLElement) { } describe("ProjectCodeField", () => { - it("renders a select named projectCode with an empty option + every fixed code", () => { - const { container } = render(); + it("renders a select named projectCode with an empty option + every supplied code", () => { + const { container } = render(); const select = container.querySelector("select"); expect(select?.getAttribute("name")).toBe("projectCode"); const opts = options(container); - // empty "none" option first, then exactly the fixed codes + // empty "none" option first, then exactly the supplied codes expect(opts[0].value).toBe(""); - expect(opts.slice(1).map((o) => o.value)).toEqual([...PROJECT_CODES]); + expect(opts.slice(1).map((o) => o.value)).toEqual(OPTIONS); }); - it("selects a current value that is one of the fixed codes (no duplicate option)", () => { - const { container } = render(); + it("selects a current value that is one of the options (no duplicate option)", () => { + const { container } = render(); const select = container.querySelector("select") as HTMLSelectElement; expect(select.value).toBe("Haldia Reach"); - // only the fixed codes + empty option — no extra "(current)" entry - expect(container.querySelectorAll("option")).toHaveLength(PROJECT_CODES.length + 1); + // only the options + empty option — no extra "(current)" entry + expect(container.querySelectorAll("option")).toHaveLength(OPTIONS.length + 1); }); it("preserves a legacy current value not in the list as a leading (current) option", () => { - const { container } = render(); + const { container } = render(); const select = container.querySelector("select") as HTMLSelectElement; expect(select.value).toBe("Legacy Project X"); expect(screen.getByText("Legacy Project X (current)")).toBeInTheDocument(); - // empty + (current) + fixed codes - expect(container.querySelectorAll("option")).toHaveLength(PROJECT_CODES.length + 2); + // empty + (current) + options + expect(container.querySelectorAll("option")).toHaveLength(OPTIONS.length + 2); }); it("defaults to the empty option when no current value is given", () => { - const { container } = render(); + const { container } = render(); const select = container.querySelector("select") as HTMLSelectElement; expect(select.value).toBe(""); }); + + it("renders just the empty option when no codes are configured", () => { + const { container } = render(); + const opts = options(container); + expect(opts).toHaveLength(1); + expect(opts[0].value).toBe(""); + }); }); -- 2.45.3