Merge pull request 'fix: New PO Project Code Selection' (#126) from claude/issue-124 into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #126
This commit is contained in:
commit
7980d68a7a
18 changed files with 584 additions and 9 deletions
|
|
@ -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 `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a 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 location is therefore always safe (no PO references it).
|
||||
|
||||
### Project Codes (issue #124)
|
||||
|
||||
`ProjectCode` (a unique `code` string + `isActive`) is an admin-managed list that backs the PO **Project Code** dropdown — it replaced an earlier hardcoded `PROJECT_CODES` array. Managed at `/admin/project-codes`, gated by the **`manage_project_codes`** permission (Manager + SuperUser + Admin), mirroring Delivery Locations (table + Add/Edit dialogs + activate/deactivate + delete). The migration seeds the five originally-hardcoded codes so the dropdown stays populated.
|
||||
|
||||
The three PO forms render a shared `<ProjectCodeField options={…}>` — a native `<select name="projectCode">` 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**.
|
||||
|
|
|
|||
82
App/app/(portal)/admin/project-codes/actions.ts
Normal file
82
App/app/(portal)/admin/project-codes/actions.ts
Normal file
|
|
@ -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<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 };
|
||||
}
|
||||
28
App/app/(portal)/admin/project-codes/page.tsx
Normal file
28
App/app/(portal)/admin/project-codes/page.tsx
Normal file
|
|
@ -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 (
|
||||
<ProjectCodesTable
|
||||
projectCodes={projectCodes.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
isActive: c.isActive,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
App/app/(portal)/admin/project-codes/project-code-form.tsx
Normal file
96
App/app/(portal)/admin/project-codes/project-code-form.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Project code *</label>
|
||||
<input name="code" defaultValue={projectCode?.code ?? ""} required className={INPUT} placeholder="e.g. Petronet LNG Cochin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
+ Add Project Code
|
||||
</button>
|
||||
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Project Code">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Fields />
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Project Code">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Fields projectCode={projectCode} />
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
131
App/app/(portal)/admin/project-codes/project-codes-table.tsx
Normal file
131
App/app/(portal)/admin/project-codes/project-codes-table.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||
{projectCode.isActive ? "Deactivate" : "Activate"}
|
||||
</RowActionsItem>
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
|
||||
<EditProjectCodeButton projectCode={projectCode} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<DeleteConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
label={projectCode.code}
|
||||
onConfirm={() => deleteProjectCode(projectCode.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={toggleOpen}
|
||||
onOpenChange={setToggleOpen}
|
||||
title={projectCode.isActive ? "Deactivate project code?" : "Activate project code?"}
|
||||
description={
|
||||
projectCode.isActive
|
||||
? "It will no longer appear in the Project Code dropdown."
|
||||
: "It will appear in the Project Code dropdown again."
|
||||
}
|
||||
confirmLabel={projectCode.isActive ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => toggleProjectCodeActive(projectCode.id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectCodesTable({ projectCodes }: { projectCodes: ProjectCodeRow[] }) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<ProjectCodeRow>({
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Project Codes</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Codes that populate the PO “Project Code” dropdown</p>
|
||||
</div>
|
||||
<AddProjectCodeButton />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search project code…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>Project Code</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-neutral-400">
|
||||
No project codes yet. Add one to populate the Project Code dropdown.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((projectCode) => (
|
||||
<tr key={projectCode.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{projectCode.code}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
projectCode.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{projectCode.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ProjectCodeActionsMenu projectCode={projectCode} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/p
|
|||
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 type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ interface Props {
|
|||
vendors: Vendor[];
|
||||
companies: CompanyOption[];
|
||||
deliveryOptions: string[];
|
||||
projectCodeOptions: string[];
|
||||
termsCatalogue: CatalogueCategory[];
|
||||
initialTerms: PoTerm[];
|
||||
}
|
||||
|
|
@ -57,7 +59,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
|||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -195,7 +197,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
|||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>Project Code</label>
|
||||
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
|
||||
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>Delivery Date Required</label>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -45,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<LineItemInput[]>(
|
||||
po.lineItems.map((li) => ({
|
||||
|
|
@ -197,7 +199,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
||||
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT_CLS} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -30,6 +31,7 @@ interface Props {
|
|||
vendors: Vendor[];
|
||||
companies: CompanyOption[];
|
||||
deliveryOptions: string[];
|
||||
projectCodeOptions: string[];
|
||||
termsCatalogue: CatalogueCategory[];
|
||||
defaultTerms: PoTerm[];
|
||||
initialLineItems?: LineItemInput[];
|
||||
|
|
@ -38,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<LineItemInput[]>(
|
||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||
|
|
@ -161,7 +163,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
||||
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
|
||||
{projectCodeOptions.length === 0 && (
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
No project codes configured yet — a Manager can add them under Administration → Project Codes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
App/components/po/project-code-field.tsx
Normal file
35
App/components/po/project-code-field.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Project Code dropdown (issue #124) — a native <select name="projectCode">
|
||||
* sourced from the admin-managed project codes, plus an empty "— none —" option
|
||||
* (the field stays optional). Plain HTML so it works with the forms' native
|
||||
* FormData submission (no client state needed), matching DeliveryLocationField.
|
||||
*
|
||||
* `options` are the active project-code strings (also the stored value).
|
||||
* `current` is the PO's existing project code; if it isn't one of the active
|
||||
* options (legacy / imported / a since-removed code) it is preserved as a
|
||||
* leading "(current)" option so an edit never silently drops it.
|
||||
*/
|
||||
export function ProjectCodeField({
|
||||
options,
|
||||
current,
|
||||
className,
|
||||
}: {
|
||||
options: string[];
|
||||
current?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const cur = (current ?? "").trim();
|
||||
const currentMissing = cur.length > 0 && !options.includes(cur);
|
||||
|
||||
return (
|
||||
<select name="projectCode" defaultValue={cur} className={className}>
|
||||
<option value="">— none —</option>
|
||||
{currentMissing && <option value={cur}>{cur} (current)</option>}
|
||||
{options.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Role, Permission[]> = {
|
|||
"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<Role, Permission[]> = {
|
|||
"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<Role, Permission[]> = {
|
|||
"manage_products",
|
||||
"manage_sites",
|
||||
"manage_delivery_locations",
|
||||
"manage_project_codes",
|
||||
"manage_terms",
|
||||
],
|
||||
SITE_STAFF: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
App/tests/integration/project-codes.test.ts
Normal file
81
App/tests/integration/project-codes.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
55
App/tests/unit/project-code-field.test.tsx
Normal file
55
App/tests/unit/project-code-field.test.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ProjectCodeField } from "@/components/po/project-code-field";
|
||||
|
||||
const OPTIONS = ["Petronet LNG Cochin", "Haldia Reach", "COMACOE Mandvi"];
|
||||
|
||||
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 supplied code", () => {
|
||||
const { container } = render(<ProjectCodeField options={OPTIONS} />);
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("name")).toBe("projectCode");
|
||||
|
||||
const opts = options(container);
|
||||
// empty "none" option first, then exactly the supplied codes
|
||||
expect(opts[0].value).toBe("");
|
||||
expect(opts.slice(1).map((o) => o.value)).toEqual(OPTIONS);
|
||||
});
|
||||
|
||||
it("selects a current value that is one of the options (no duplicate option)", () => {
|
||||
const { container } = render(<ProjectCodeField options={OPTIONS} current="Haldia Reach" />);
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
expect(select.value).toBe("Haldia Reach");
|
||||
// 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(<ProjectCodeField options={OPTIONS} current="Legacy Project X" />);
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
expect(select.value).toBe("Legacy Project X");
|
||||
expect(screen.getByText("Legacy Project X (current)")).toBeInTheDocument();
|
||||
// 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(<ProjectCodeField options={OPTIONS} current={null} />);
|
||||
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(<ProjectCodeField options={[]} />);
|
||||
const opts = options(container);
|
||||
expect(opts).toHaveLength(1);
|
||||
expect(opts[0].value).toBe("");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue