Compare commits

..

No commits in common. "master" and "claude/busy-boyd-b16092" have entirely different histories.

40 changed files with 98 additions and 1273 deletions

View file

@ -104,12 +104,6 @@ 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). 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) ### Terms & Conditions catalogue (issue #11)
Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**. Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**.
@ -147,8 +141,6 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.** The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey``po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
### Inventory (feature-flagged) ### Inventory (feature-flagged)
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless. Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
@ -172,8 +164,6 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection). **Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep.
Sites are **not** cost centres (only vessels are). Sites are **not** cost centres (only vessels are).
### Crewing (feature-flagged) ### Crewing (feature-flagged)

View file

@ -1,82 +0,0 @@
"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 };
}

View file

@ -1,28 +0,0 @@
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,
}))}
/>
);
}

View file

@ -1,96 +0,0 @@
"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>
);
}

View file

@ -1,131 +0,0 @@
"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 &ldquo;Project Code&rdquo; 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>
);
}

View file

@ -10,7 +10,6 @@ import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/p
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select"; import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; 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 { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -44,7 +43,6 @@ interface Props {
vendors: Vendor[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
projectCodeOptions: string[];
termsCatalogue: CatalogueCategory[]; termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[]; initialTerms: PoTerm[];
} }
@ -59,7 +57,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />; return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
} }
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms }: Props) { export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) {
const router = useRouter(); const router = useRouter();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -197,7 +195,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
</div> </div>
<div> <div>
<label className={LABEL}>Project Code</label> <label className={LABEL}>Project Code</label>
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT} /> <input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
</div> </div>
<div> <div>
<label className={LABEL}>Delivery Date Required</label> <label className={LABEL}>Delivery Date Required</label>

View file

@ -32,7 +32,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
}); });
const hasSignature = !!(currentUser?.signatureKey); const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([ const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
db.purchaseOrder.findUnique({ db.purchaseOrder.findUnique({
where: { id }, where: { id },
include: { include: {
@ -56,7 +56,6 @@ export default async function ApprovalDetailPage({ params }: Props) {
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), 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.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.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(); if (!po) notFound();
@ -64,7 +63,6 @@ export default async function ApprovalDetailPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const projectCodeOptions = projectCodes.map((c) => c.code);
const termsCatalogue = await getTermsCatalogue(); const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms); const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po); const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
@ -109,7 +107,6 @@ export default async function ApprovalDetailPage({ params }: Props) {
vendors={vendors} vendors={vendors}
companies={companies} companies={companies}
deliveryOptions={deliveryOptions} deliveryOptions={deliveryOptions}
projectCodeOptions={projectCodeOptions}
termsCatalogue={termsCatalogue} termsCatalogue={termsCatalogue}
initialTerms={initialTerms} initialTerms={initialTerms}
/> />

View file

@ -2,8 +2,6 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
const STATUSES = [ const STATUSES = [
{ value: "DRAFT", label: "Draft" }, { value: "DRAFT", label: "Draft" },
@ -21,12 +19,11 @@ const STATUSES = [
interface Props { interface Props {
vessels: { id: string; name: string }[]; vessels: { id: string; name: string }[];
accounts: AccountGroup[];
perPageOptions: number[]; perPageOptions: number[];
defaultPerPage: number; defaultPerPage: number;
} }
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) { export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
const router = useRouter(); const router = useRouter();
const sp = useSearchParams(); const sp = useSearchParams();
@ -36,8 +33,9 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? ""); const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status")); const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(null); const statusRef = useRef<HTMLDivElement>(null);
@ -63,8 +61,9 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
const params = new URLSearchParams(); const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom); if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo); if (dateTo) params.set("dateTo", dateTo);
if (approvedFrom) params.set("approvedFrom", approvedFrom);
if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId); if (vesselId) params.set("vesselId", vesselId);
if (accountId) params.set("accountId", accountId);
for (const s of statuses) params.append("status", s); for (const s of statuses) params.append("status", s);
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage)); if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
return params; return params;
@ -79,14 +78,14 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
} }
function clear() { function clear() {
setDateFrom(""); setDateTo(""); setVesselId(""); setAccountId(""); setStatuses([]); setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (perPage !== defaultPerPage) params.set("perPage", String(perPage)); if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
const qs = params.toString(); const qs = params.toString();
router.push(qs ? `/history?${qs}` : "/history"); router.push(qs ? `/history?${qs}` : "/history");
} }
const hasFilters = dateFrom || dateTo || vesselId || accountId || statuses.length > 0; const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
const statusLabel = const statusLabel =
statuses.length === 0 statuses.length === 0
@ -108,6 +107,16 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} <input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="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" /> className="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" />
</div> </div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
<input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)}
className="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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
<input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)}
className="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" />
</div>
<div> <div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label> <label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)} <select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
@ -116,16 +125,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)} {vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select> </select>
</div> </div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
<SearchableSelect
name="accountId"
value={accountId}
onChange={setAccountId}
groups={accounts}
placeholder="All accounting codes"
/>
</div>
<div className="relative" ref={statusRef}> <div className="relative" ref={statusRef}>
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label> <label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
<button type="button" onClick={() => setStatusOpen((o) => !o)} <button type="button" onClick={() => setStatusOpen((o) => !o)}

View file

@ -6,11 +6,10 @@ import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge"; import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters"; import { HistoryFilters } from "./history-filters";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { resolvePagination } from "@/lib/pagination"; import { resolvePagination } from "@/lib/pagination";
import { Suspense } from "react"; import { Suspense } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { POStatus } from "@prisma/client";
export const metadata: Metadata = { title: "History" }; export const metadata: Metadata = { title: "History" };
@ -24,7 +23,6 @@ interface Props {
approvedFrom?: string; approvedFrom?: string;
approvedTo?: string; approvedTo?: string;
vesselId?: string; vesselId?: string;
accountId?: string;
status?: string | string[]; status?: string | string[];
page?: string; page?: string;
perPage?: string; perPage?: string;
@ -44,13 +42,33 @@ export default async function HistoryPage({ searchParams }: Props) {
redirect("/dashboard"); redirect("/dashboard");
} }
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } = const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
await searchParams; await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) {
const createdAt: { gte?: Date; lt?: Date } = {};
if (dateFrom) createdAt.gte = new Date(dateFrom);
if (dateTo) {
const end = new Date(dateTo);
end.setDate(end.getDate() + 1);
createdAt.lt = end;
}
where.createdAt = createdAt;
}
if (approvedFrom || approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
if (approvedTo) {
const end = new Date(approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (vesselId) where.vesselId = vesselId;
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean); const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
const where = await buildPoHistoryWhere({ if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses,
});
const total = await db.purchaseOrder.count({ where }); const total = await db.purchaseOrder.count({ where });
const { perPage, page, totalPages, skip, take } = resolvePagination({ const { perPage, page, totalPages, skip, take } = resolvePagination({
@ -61,7 +79,7 @@ export default async function HistoryPage({ searchParams }: Props) {
defaultPerPage: DEFAULT_PER_PAGE, defaultPerPage: DEFAULT_PER_PAGE,
}); });
const [orders, vessels, leafAccounts] = await Promise.all([ const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({ db.purchaseOrder.findMany({
where, where,
include: { submitter: true, vessel: true, account: true }, include: { submitter: true, vessel: true, account: true },
@ -70,15 +88,8 @@ export default async function HistoryPage({ searchParams }: Props) {
take, take,
}), }),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
]); ]);
const accounts = buildAccountGroups(leafAccounts);
// Shared filter params for the pagination footer links (everything except `page`). // Shared filter params for the pagination footer links (everything except `page`).
const pageParams = new URLSearchParams(); const pageParams = new URLSearchParams();
if (dateFrom) pageParams.set("dateFrom", dateFrom); if (dateFrom) pageParams.set("dateFrom", dateFrom);
@ -86,7 +97,6 @@ export default async function HistoryPage({ searchParams }: Props) {
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom); if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
if (approvedTo) pageParams.set("approvedTo", approvedTo); if (approvedTo) pageParams.set("approvedTo", approvedTo);
if (vesselId) pageParams.set("vesselId", vesselId); if (vesselId) pageParams.set("vesselId", vesselId);
if (accountId) pageParams.set("accountId", accountId);
for (const s of statuses) pageParams.append("status", s); for (const s of statuses) pageParams.append("status", s);
pageParams.set("perPage", String(perPage)); pageParams.set("perPage", String(perPage));
@ -105,7 +115,6 @@ export default async function HistoryPage({ searchParams }: Props) {
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom); if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
if (approvedTo) exportParams.set("approvedTo", approvedTo); if (approvedTo) exportParams.set("approvedTo", approvedTo);
if (vesselId) exportParams.set("vesselId", vesselId); if (vesselId) exportParams.set("vesselId", vesselId);
if (accountId) exportParams.set("accountId", accountId);
for (const s of statuses) exportParams.append("status", s); for (const s of statuses) exportParams.append("status", s);
return ( return (
@ -131,7 +140,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div> </div>
<Suspense> <Suspense>
<HistoryFilters vessels={vessels} accounts={accounts} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} /> <HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
</Suspense> </Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -141,7 +150,6 @@ export default async function HistoryPage({ searchParams }: Props) {
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Accounting Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th> <th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
@ -161,9 +169,6 @@ export default async function HistoryPage({ searchParams }: Props) {
</td> </td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td> <td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-neutral-600">
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.name}
</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td> <td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />

View file

@ -9,7 +9,6 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select"; import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; 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 { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -46,13 +45,12 @@ interface Props {
vendors: Vendor[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
projectCodeOptions: string[];
termsCatalogue: CatalogueCategory[]; termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[]; initialTerms: PoTerm[];
managerNoteAuthor?: string | null; managerNoteAuthor?: string | null;
} }
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) { export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
const router = useRouter(); const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({ po.lineItems.map((li) => ({
@ -199,7 +197,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT_CLS} /> <input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>

View file

@ -32,7 +32,7 @@ export default async function EditPoPage({ params }: Props) {
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER"; const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`); if (!canEdit) redirect(`/po/${id}`);
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes, noteAction] = await Promise.all([ const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
@ -42,7 +42,6 @@ export default async function EditPoPage({ params }: Props) {
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), 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.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.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" po.status === "EDITS_REQUESTED"
? db.pOAction.findFirst({ ? db.pOAction.findFirst({
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } }, where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
@ -54,7 +53,6 @@ export default async function EditPoPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const projectCodeOptions = projectCodes.map((c) => c.code);
const termsCatalogue = await getTermsCatalogue(); const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms); const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po); const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
@ -84,7 +82,6 @@ export default async function EditPoPage({ params }: Props) {
vendors={vendors} vendors={vendors}
companies={companies} companies={companies}
deliveryOptions={deliveryOptions} deliveryOptions={deliveryOptions}
projectCodeOptions={projectCodeOptions}
termsCatalogue={termsCatalogue} termsCatalogue={termsCatalogue}
initialTerms={initialTerms} initialTerms={initialTerms}
managerNoteAuthor={noteAction?.actor.name ?? null} managerNoteAuthor={noteAction?.actor.name ?? null}

View file

@ -2,7 +2,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage"; import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service"; import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
type Result = { ok: true; mailto: string; to: string } | { error: string }; type Result = { ok: true; mailto: string; to: string } | { error: string };
@ -47,20 +47,13 @@ export async function prepareVendorEmail(poId: string): Promise<Result> {
return { error: "PDF emailing is not configured on this environment." }; return { error: "PDF emailing is not configured on this environment." };
} }
// Render → store → presigned link. The PDF is cached at a deterministic // Render → store → presigned link.
// per-PO key: if a copy already exists and is at least as new as the PO's last
// change, reuse it and only mint a fresh presigned URL (refreshing the 7-day
// timer). Re-render only when there's no copy yet or the PO changed since.
let link: string; let link: string;
try { try {
const slug = po.poNumber.replace(/\//g, "-");
const key = buildPoPdfKey(poId, `${slug}.pdf`);
const cached = await statObject(key);
const isFresh = cached !== null && cached.lastModified >= po.updatedAt;
if (!isFresh) {
const pdf = await renderPoPdf(poId); const pdf = await renderPoPdf(poId);
const slug = po.poNumber.replace(/\//g, "-");
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
await uploadBuffer(key, pdf, "application/pdf"); await uploadBuffer(key, pdf, "application/pdf");
}
link = await generateDownloadUrl(key, LINK_TTL_SECONDS); link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
} catch (e) { } catch (e) {
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` }; if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };

View file

@ -9,7 +9,6 @@ import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select"; import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field"; 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 { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard"; import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms"; import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -31,7 +30,6 @@ interface Props {
vendors: Vendor[]; vendors: Vendor[];
companies: CompanyOption[]; companies: CompanyOption[];
deliveryOptions: string[]; deliveryOptions: string[];
projectCodeOptions: string[];
termsCatalogue: CatalogueCategory[]; termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[]; defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[]; initialLineItems?: LineItemInput[];
@ -40,7 +38,7 @@ interface Props {
initialCompanyId?: string; initialCompanyId?: string;
} }
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) { export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter(); const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -163,12 +161,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} /> <input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
{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>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label> <label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>

View file

@ -48,7 +48,7 @@ export default async function NewPoPage({ searchParams }: Props) {
} }
} }
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([ const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
@ -58,12 +58,10 @@ export default async function NewPoPage({ searchParams }: Props) {
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), 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.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.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 accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address)); 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()]); const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
return ( return (
@ -80,7 +78,6 @@ export default async function NewPoPage({ searchParams }: Props) {
vendors={vendors} vendors={vendors}
companies={companies} companies={companies}
deliveryOptions={deliveryOptions} deliveryOptions={deliveryOptions}
projectCodeOptions={projectCodeOptions}
termsCatalogue={termsCatalogue} termsCatalogue={termsCatalogue}
defaultTerms={defaultTerms} defaultTerms={defaultTerms}
initialLineItems={initialLineItems} initialLineItems={initialLineItems}

View file

@ -11,7 +11,6 @@ import {
accountNodeWeekly, accountNodeWeekly,
costCentresForAccount, costCentresForAccount,
childBreakdown, childBreakdown,
periodRange,
parseGranularity, parseGranularity,
resolveFy, resolveFy,
resolveMonth, resolveMonth,
@ -90,10 +89,6 @@ export default async function AccountingCodeDetail({
return `${base}?${p.toString()}`; return `${base}?${p.toString()}`;
}; };
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`; const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
// Drill into the POs behind this spend: PO History filtered to this accounting
// code (expanded to its leaves) over the period in view (dated by approvedAt).
const { from, to } = periodRange(gran, fy, month, ds.fys);
const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`;
const path = idx.pathTo(id); const path = idx.pathTo(id);
const trail = [ const trail = [
@ -116,17 +111,12 @@ export default async function AccountingCodeDetail({
exportHref={exportHref} exportHref={exportHref}
/> />
<div className="mb-4 flex items-center justify-between gap-3">
<Link <Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`} href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700" className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
> >
Back to Accounting Codes Back to Accounting Codes
</Link> </Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle <ReportTitle
title={`${node.code} · ${node.name}`} title={`${node.code} · ${node.name}`}

View file

@ -10,7 +10,6 @@ import {
costCentreRows, costCentreRows,
costCentreWeekly, costCentreWeekly,
topAccountsForCostCentre, topAccountsForCostCentre,
periodRange,
parseGranularity, parseGranularity,
resolveFy, resolveFy,
resolveMonth, resolveMonth,
@ -81,10 +80,6 @@ export default async function CostCentreDetail({
return `${base}?${p.toString()}`; return `${base}?${p.toString()}`;
}; };
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`; const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
// Drill into the POs behind this spend: PO History filtered to this cost centre
// over the period currently in view (spend is dated by approvedAt).
const { from, to } = periodRange(gran, fy, month, ds.fys);
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
return ( return (
<div> <div>
@ -98,14 +93,9 @@ export default async function CostCentreDetail({
exportHref={exportHref} exportHref={exportHref}
/> />
<div className="mb-4 flex items-center justify-between gap-3"> <Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres Back to Cost Centres
</Link> </Link>
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
View POs · {periodLabel}
</Link>
</div>
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} /> <ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />

View file

@ -1,8 +1,8 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client";
const PO_STATUS_LABELS: Record<string, string> = { const PO_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval", DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
@ -25,16 +25,36 @@ export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams; const sp = request.nextUrl.searchParams;
const format = sp.get("format") ?? "csv"; const format = sp.get("format") ?? "csv";
const dateFrom = sp.get("dateFrom");
const dateTo = sp.get("dateTo");
const approvedFrom = sp.get("approvedFrom");
const approvedTo = sp.get("approvedTo");
const vesselId = sp.get("vesselId");
const statuses = sp.getAll("status").filter(Boolean);
const where = await buildPoHistoryWhere({ const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
dateFrom: sp.get("dateFrom"), if (dateFrom || dateTo) {
dateTo: sp.get("dateTo"), const createdAt: { gte?: Date; lt?: Date } = {};
approvedFrom: sp.get("approvedFrom"), if (dateFrom) createdAt.gte = new Date(dateFrom);
approvedTo: sp.get("approvedTo"), if (dateTo) {
vesselId: sp.get("vesselId"), const end = new Date(dateTo);
accountId: sp.get("accountId"), end.setDate(end.getDate() + 1);
statuses: sp.getAll("status"), createdAt.lt = end;
}); }
where.createdAt = createdAt;
}
if (approvedFrom || approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
if (approvedTo) {
const end = new Date(approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (vesselId) where.vesselId = vesselId;
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const orders = await db.purchaseOrder.findMany({ const orders = await db.purchaseOrder.findMany({
where, where,

View file

@ -35,7 +35,6 @@ import {
Gauge, Gauge,
BadgeCheck, BadgeCheck,
Truck, Truck,
FolderKanban,
ScrollText, ScrollText,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
@ -124,7 +123,6 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] }, { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "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/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"] }, { 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 reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED ...(CREWING_ENABLED

View file

@ -1,35 +0,0 @@
/**
* 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>
);
}

View file

@ -1,68 +0,0 @@
/**
* Shared `where` builder for the PO History list (`/history` page) and its
* CSV/PDF export route, so the two never drift. Filters: created-date range,
* approved-date range, cost centre (vessel), status, and for report
* drill-downs (issue #124 review) an accounting code.
*
* The `accountId` filter accepts any account-tree node (Heading / Sub-heading /
* Leaf); it expands to the leaf codes underneath via `accountLeafIds` and
* matches a PO whose **PO-level account** or **any line item account** is in
* that leaf set the same attribution basis the spend reports use.
*/
import { db } from "@/lib/db";
import { accountLeafIds } from "@/lib/reports";
import type { POStatus } from "@prisma/client";
type PoWhere = NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"];
export interface HistoryFilterParams {
dateFrom?: string | null;
dateTo?: string | null;
approvedFrom?: string | null;
approvedTo?: string | null;
vesselId?: string | null;
accountId?: string | null;
statuses?: string[];
}
export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise<PoWhere> {
const where: NonNullable<PoWhere> = {};
if (p.dateFrom || p.dateTo) {
const createdAt: { gte?: Date; lt?: Date } = {};
if (p.dateFrom) createdAt.gte = new Date(p.dateFrom);
if (p.dateTo) {
const end = new Date(p.dateTo);
end.setDate(end.getDate() + 1);
createdAt.lt = end;
}
where.createdAt = createdAt;
}
if (p.approvedFrom || p.approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom);
if (p.approvedTo) {
const end = new Date(p.approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (p.vesselId) where.vesselId = p.vesselId;
const statuses = (p.statuses ?? []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
if (p.accountId) {
const accounts = await db.account.findMany({ select: { id: true, parentId: true } });
const leaves = accountLeafIds(accounts, p.accountId);
where.OR = [
{ accountId: { in: leaves } },
{ lineItems: { some: { accountId: { in: leaves } } } },
];
}
return where;
}

View file

@ -1,24 +0,0 @@
// Service-token auth for the PO export route, shared by the auth middleware and
// (conceptually) the export route handler.
//
// PdfService ("Email PO to vendor", issue #14) fetches `/api/po/<id>/export`
// WITHOUT a user session, authenticating with a `svc` query param that must equal
// PDF_SERVICE_TOKEN. The route handler validates that token, but the auth
// middleware runs first and would otherwise redirect the unauthenticated request
// to /login — so the middleware uses this to let exactly that one route through
// when the token matches.
//
// Kept dependency-free so it's safe to import into the Edge middleware and easy to
// unit-test. `token` is `process.env.PDF_SERVICE_TOKEN` (undefined when the PDF
// service isn't configured → always denied).
const EXPORT_PATH = /^\/api\/po\/[^/]+\/export\/?$/;
export function isPdfExportServiceRequest(
pathname: string,
svc: string | null | undefined,
token: string | undefined
): boolean {
if (!token || !svc) return false;
if (svc !== token) return false;
return EXPORT_PATH.test(pathname);
}

View file

@ -23,7 +23,6 @@ export type Permission =
| "manage_products" | "manage_products"
| "manage_sites" | "manage_sites"
| "manage_delivery_locations" | "manage_delivery_locations"
| "manage_project_codes"
| "manage_terms" | "manage_terms"
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ────── // ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
| "raise_requisition" | "raise_requisition"
@ -85,7 +84,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products", "manage_products",
"manage_sites", "manage_sites",
"manage_delivery_locations", "manage_delivery_locations",
"manage_project_codes",
"manage_terms", "manage_terms",
"confirm_receipt", "confirm_receipt",
"process_payment" "process_payment"
@ -107,7 +105,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"export_reports", "export_reports",
"create_vendor", "create_vendor",
"manage_delivery_locations", "manage_delivery_locations",
"manage_project_codes",
"manage_terms", "manage_terms",
], ],
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"], AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
@ -123,7 +120,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products", "manage_products",
"manage_sites", "manage_sites",
"manage_delivery_locations", "manage_delivery_locations",
"manage_project_codes",
"manage_terms", "manage_terms",
], ],
SITE_STAFF: [], SITE_STAFF: [],

View file

@ -350,65 +350,3 @@ export function parseSel(v: string | undefined): string[] {
export function toggleSel(sel: string[], id: string): string[] { export function toggleSel(sel: string[], id: string): string[] {
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id]; return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
} }
// ── Report → PO drill-down ─────────────────────────────────────────────────
/**
* Leaf account ids under `accountId` (the node itself when it is already a
* leaf), from the raw `{ id, parentId }` account rows. A report drill-down can
* target any tier, but a PO / line item only ever carries a leaf code so this
* translates a drilled node into the concrete leaf set PO History filters by.
* Returns `[]` for an unknown id.
*/
export function accountLeafIds(
accounts: { id: string; parentId: string | null }[],
accountId: string,
): string[] {
const ids = new Set(accounts.map((a) => a.id));
if (!ids.has(accountId)) return [];
const kids = new Map<string, string[]>();
for (const a of accounts) {
if (a.parentId === null) continue;
if (!kids.has(a.parentId)) kids.set(a.parentId, []);
kids.get(a.parentId)!.push(a.id);
}
const out: string[] = [];
const walk = (id: string) => {
const cs = kids.get(id) ?? [];
if (cs.length === 0) out.push(id);
else cs.forEach(walk);
};
walk(accountId);
return out;
}
/**
* The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report
* detail view currently shows, so drilling into the underlying POs carries
* "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by
* `approvedAt`). Mirrors the on-screen period label:
* - weekly the focused FY month
* - monthly the whole selected FY (AprMar)
* - yearly the full span of FYs in the dataset
*/
export function periodRange(
gran: Granularity,
fy: number,
month: number,
fys: number[],
): { from: string; to: string } {
const iso = (y: number, m: number, d: number) =>
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
if (gran === "yearly") {
const first = fys[0] ?? fy;
const last = fys[fys.length - 1] ?? fy;
return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) };
}
if (gran === "weekly") {
const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 011
const year = fy + (month >= 9 ? 1 : 0); // JanMar roll into the next calendar year
const lastDay = new Date(year, cal + 1, 0).getDate();
return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) };
}
return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) };
}

View file

@ -59,16 +59,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
return `signatures/${userId}.${ext}`; return `signatures/${userId}.${ext}`;
} }
/**
* Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so
* "Email to vendor" can reuse a previously rendered copy instead of re-rendering
* and re-uploading on every send (see `prepareVendorEmail`).
*/
export function buildPoPdfKey(poId: string, fileName: string): string {
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `po-pdf/${poId}/${safe}`;
}
/** /**
* Storage key for a company branding asset (logo or stamp/seal). * Storage key for a company branding asset (logo or stamp/seal).
* Deterministic per company+type so a re-upload overwrites the previous file. * Deterministic per company+type so a re-upload overwrites the previous file.
@ -116,36 +106,6 @@ export async function uploadBuffer(
} }
} }
/**
* Lightweight existence/metadata check for a stored object (no body transfer).
* Returns `{ lastModified }` when the object exists, or `null` when it doesn't.
* Used to reuse a cached PO PDF when it's still current.
*/
export async function statObject(key: string): Promise<{ lastModified: Date } | null> {
try {
if (isDev) {
const fs = await import("fs/promises");
const path = await import("path");
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
const s = await fs.stat(filePath);
return { lastModified: s.mtime };
}
const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }));
return { lastModified: r.LastModified ?? new Date(0) };
} catch {
return null; // missing object (404/NotFound) or any access error → treat as absent
}
}
/** /**
* Fetch a stored file as a Buffer (server-side). * Fetch a stored file as a Buffer (server-side).
*/ */

View file

@ -1,20 +1,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
export default auth((req) => { export default auth((req) => {
const isAuthenticated = !!req.auth; const isAuthenticated = !!req.auth;
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;
const isLoginPage = pathname === "/login"; const isLoginPage = pathname === "/login";
// PdfService fetches the PO export page unauthenticated, using a `svc` token
// that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that
// one route through so the service token isn't bounced to /login by the gate
// below. Everything else stays auth-protected.
if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) {
return NextResponse.next();
}
if (!isAuthenticated && !isLoginPage) { if (!isAuthenticated && !isLoginPage) {
const loginUrl = new URL("/login", req.url); const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);

View file

@ -1,22 +0,0 @@
-- 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);

View file

@ -410,19 +410,6 @@ model DeliveryLocation {
@@index([companyId]) @@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 // 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 // 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 // line is a catalogued clause, including the standard "fixed" lines (seeded under

View file

@ -17,15 +17,13 @@ vi.mock("@/lib/storage", async (importOriginal) => {
...actual, ...actual,
uploadBuffer: vi.fn(async () => {}), uploadBuffer: vi.fn(async () => {}),
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"), generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
statObject: vi.fn(async () => null), // default: no cached object → render
}; };
}); });
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions"; import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service"; import { isPdfServiceConfigured } from "@/lib/pdf-service";
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers"; import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_"; const PREFIX = "INTTEST_EMAILVENDOR_";
@ -100,34 +98,6 @@ describe("prepareVendorEmail", () => {
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc"); expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
}); });
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// 1st send: no cached object → render + upload once.
vi.mocked(statObject).mockResolvedValueOnce(null);
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
});
it("re-renders when the PO changed since the cached copy", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// Cached object older than the PO's updatedAt → stale → re-render.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
});
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => { it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
as(techId, "TECHNICAL"); as(techId, "TECHNICAL");
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId); const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);

View file

@ -1,128 +0,0 @@
/**
* Integration test for the PO History accounting-code filter (PR #126 review).
*
* Report drill-downs link from a cost-centre / accounting-code detail page into
* `/history` with the code (and period) applied as filters. `buildPoHistoryWhere`
* powers both the History page and its export route. The accounting-code filter
* accepts any tree node and must match a PO whose **PO-level account** OR **any
* line item account** falls under that node's leaves the same attribution
* basis the spend reports use.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { db } from "@/lib/db";
import { buildPoHistoryWhere } from "@/lib/history-filter";
import { deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_HIST_ACCT_";
const CODE = `INTTEST_${Date.now()}_`;
let submitterId: string;
let vesselId: string;
let topId: string; // heading
let leafAId: string;
let leafBId: string;
let leafXId: string; // unrelated leaf
const approvedAt = new Date("2025-06-15T12:00:00Z"); // FY 202526
async function makePo(opts: {
title: string;
accountId: string;
lineAccountId?: string | null;
}) {
return db.purchaseOrder.create({
data: {
poNumber: `${PREFIX}${opts.title}`,
title: `${PREFIX}${opts.title}`,
status: "CLOSED",
totalAmount: 1000,
approvedAt,
submitterId,
vesselId,
accountId: opts.accountId,
lineItems: {
create: [
{
name: "Item",
quantity: 1,
unit: "pc",
unitPrice: 1000,
totalPrice: 1000,
accountId: opts.lineAccountId ?? null,
},
],
},
},
});
}
beforeAll(async () => {
const [user, vessel] = await Promise.all([
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
db.vessel.findFirstOrThrow(),
]);
submitterId = user.id;
vesselId = vessel.id;
// Brand-new account subtree (T → S → A, B) + an unrelated leaf X, so no
// pre-existing prod PO references them and counts isolate to our fixtures.
const top = await db.account.create({ data: { code: `${CODE}T`, name: "IntTest Top" } });
const sub = await db.account.create({ data: { code: `${CODE}S`, name: "IntTest Sub", parentId: top.id } });
const a = await db.account.create({ data: { code: `${CODE}A`, name: "IntTest Leaf A", parentId: sub.id } });
const b = await db.account.create({ data: { code: `${CODE}B`, name: "IntTest Leaf B", parentId: sub.id } });
const x = await db.account.create({ data: { code: `${CODE}X`, name: "IntTest Leaf X" } });
topId = top.id;
leafAId = a.id;
leafBId = b.id;
leafXId = x.id;
await makePo({ title: "po1_levelA", accountId: leafAId }); // PO-level A
await makePo({ title: "po2_levelX_lineB", accountId: leafXId, lineAccountId: leafBId }); // line item B
await makePo({ title: "po3_levelX", accountId: leafXId }); // X only
});
afterAll(async () => {
await deletePosByTitle(PREFIX);
await db.account.deleteMany({ where: { code: { startsWith: CODE } } });
});
async function countMine(accountId: string) {
const where = await buildPoHistoryWhere({ accountId });
return db.purchaseOrder.count({ where: { ...where, title: { startsWith: PREFIX } } });
}
describe("PO History accounting-code filter", () => {
it("a heading expands to its leaves and matches PO-level or line-item accounts", async () => {
// T → {A, B}: po1 (PO-level A) and po2 (line item B); not po3 (X only).
expect(await countMine(topId)).toBe(2);
});
it("a leaf matches only POs carrying that exact code (PO-level)", async () => {
expect(await countMine(leafAId)).toBe(1); // po1
});
it("matches a PO via a line-item account even when the PO-level account differs", async () => {
expect(await countMine(leafBId)).toBe(1); // po2 (PO-level X, line item B)
});
it("the unrelated leaf matches its own POs", async () => {
expect(await countMine(leafXId)).toBe(2); // po2 + po3 (both PO-level X)
});
it("combines with the approved-date window", async () => {
const inWindow = await buildPoHistoryWhere({
accountId: topId,
approvedFrom: "2025-04-01",
approvedTo: "2026-03-31",
});
expect(await db.purchaseOrder.count({ where: { ...inWindow, title: { startsWith: PREFIX } } })).toBe(2);
const outOfWindow = await buildPoHistoryWhere({
accountId: topId,
approvedFrom: "2024-04-01",
approvedTo: "2025-03-31",
});
expect(await db.purchaseOrder.count({ where: { ...outOfWindow, title: { startsWith: PREFIX } } })).toBe(0);
});
});

View file

@ -1,81 +0,0 @@
/**
* 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" });
});
});

View file

@ -1,26 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #121 PO History gains an Accounting Code filter. The control is the
* shared type-to-search combobox; picking a code and applying narrows the list
* via an `accountId` query param (mirrored by the CSV/PDF export links).
*/
test("#121 history can be filtered by accounting code", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
// The Accounting Code control is present (its trigger shows the empty label).
const trigger = page.getByRole("button", { name: "All accounting codes" });
await expect(trigger).toBeVisible();
// Open it and pick the first accounting code from the searchable list.
await trigger.click();
await expect(page.getByPlaceholder("Type code or name…")).toBeVisible();
await page.locator(".max-h-72 button").first().click();
await page.getByRole("button", { name: "Apply" }).click();
// Applying the filter drives an accountId query param.
await expect(page).toHaveURL(/accountId=/);
});

View file

@ -1,36 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// HistoryFilters reads the URL via next/navigation; mock both hooks it uses.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(""),
}));
import { HistoryFilters } from "@/app/(portal)/history/history-filters";
const props = {
vessels: [{ id: "v1", name: "Vessel One" }],
accounts: [],
perPageOptions: [25, 50, 100],
defaultPerPage: 25,
};
describe("HistoryFilters", () => {
// Regression guard for issue #136: the "Approved From" / "Approved To" date
// filters were removed from PO History. They must not reappear.
it("does not render the Approved From / Approved To filters", () => {
render(<HistoryFilters {...props} />);
expect(screen.queryByText("Approved From")).toBeNull();
expect(screen.queryByText("Approved To")).toBeNull();
});
it("still renders the remaining filters (created-date range, cost centre, accounting code, status)", () => {
render(<HistoryFilters {...props} />);
expect(screen.getByText("From")).toBeInTheDocument();
expect(screen.getByText("To")).toBeInTheDocument();
expect(screen.getByText("Cost Centre")).toBeInTheDocument();
expect(screen.getByText("Accounting Code")).toBeInTheDocument();
expect(screen.getByText("Status")).toBeInTheDocument();
});
});

View file

@ -1,26 +0,0 @@
import { describe, it, expect } from "vitest";
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
const TOKEN = "a".repeat(64);
describe("isPdfExportServiceRequest", () => {
it("allows the export route when the svc token matches", () => {
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export", TOKEN, TOKEN)).toBe(true);
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export/", TOKEN, TOKEN)).toBe(true); // trailing slash
});
it("denies when the token is missing, empty, or wrong", () => {
expect(isPdfExportServiceRequest("/api/po/x/export", TOKEN, undefined)).toBe(false); // service not configured
expect(isPdfExportServiceRequest("/api/po/x/export", null, TOKEN)).toBe(false); // no svc on request
expect(isPdfExportServiceRequest("/api/po/x/export", "", TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po/x/export", "wrong", TOKEN)).toBe(false);
});
it("only matches the PO export route, not other paths", () => {
expect(isPdfExportServiceRequest("/api/po/x/export/extra", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po/x", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/dashboard", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/reports/spend", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po//export", TOKEN, TOKEN)).toBe(false); // empty id
});
});

View file

@ -1,55 +0,0 @@
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("");
});
});

View file

@ -21,8 +21,6 @@ import {
parseSel, parseSel,
toggleSel, toggleSel,
allocatePoSpend, allocatePoSpend,
accountLeafIds,
periodRange,
type ReportDataset, type ReportDataset,
type AccountNode, type AccountNode,
} from "@/lib/reports"; } from "@/lib/reports";
@ -51,49 +49,6 @@ const DS: ReportDataset = {
], ],
}; };
describe("accountLeafIds (report → PO drill-down)", () => {
const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId }));
it("expands a heading to every leaf underneath it", () => {
expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]);
expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]);
});
it("returns a leaf node as itself", () => {
expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]);
});
it("returns [] for an unknown id", () => {
expect(accountLeafIds(RAW, "nope")).toEqual([]);
});
});
describe("periodRange (report → PO History approved window)", () => {
it("monthly → the whole selected FY (AprMar)", () => {
expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({
from: "2025-04-01",
to: "2026-03-31",
});
});
it("yearly → the full span of FYs in the dataset", () => {
expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({
from: "2024-04-01",
to: "2026-03-31",
});
});
it("weekly → the focused FY month (Apr=0)", () => {
expect(periodRange("weekly", 2025, 0, [2025])).toEqual({
from: "2025-04-01",
to: "2025-04-30",
});
});
it("weekly → a JanMar month rolls into the next calendar year", () => {
// FY-month index 9 = Jan, which belongs to calendar year fy+1.
expect(periodRange("weekly", 2025, 9, [2025])).toEqual({
from: "2026-01-01",
to: "2026-01-31",
});
});
});
describe("financial-year helpers", () => { describe("financial-year helpers", () => {
it("maps AprMar to the Indian FY start year", () => { it("maps AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr

View file

@ -4,15 +4,6 @@
### Added ### Added
- **Reports — Purchasing spend analytics** (`view_analytics`: Manager / SuperUser / Auditor / Admin) — `/reports/cost-centres` and `/reports/accounting-codes`, each an index → drill-down → detail. KPI tiles, comparison + trend charts (one colour per item), Top-N tables, per-row sparklines, and CSV export; URL-driven filters (granularity Weekly / Monthly / Yearly, financial year, Top/Bottom-N, an "Add to graph" custom comparison). Spend = post-approval POs by `approvedAt`/`totalAmount`, allocated across each PO's line-item accounting codes. Pure, unit-tested core in `lib/reports.ts`.
- **Report → PO drill-down** (#126) — the Cost Centre and Accounting Code report detail pages gain a **"View POs"** link that opens **PO History** pre-filtered to that cost centre / accounting code and the period currently in view (mapped to the approved-date window, since spend is dated by `approvedAt`). PO History gains an **Accounting Code** filter that accepts any tree node and matches a PO whose PO-level account **or** any line-item account falls under that node's leaves. The History page and its CSV/PDF export share one `buildPoHistoryWhere` builder so they never diverge.
- **Email PO to vendor** (issue #14) — one-click Outlook draft to the vendor's primary contact with a **7-day download link** to the PO PDF. Rendered by the new **PdfService** microservice (Express + Playwright → headless Chromium) and stored in R2; the PDF is **cached per PO**, so repeat sends reuse the copy and only refresh the link.
- **Microservices**`EpfoService` (UAN / EPFO assisted-lookup proxy; live portal nav stubbed behind `EPFO_LIVE`) and `PdfService` (PO → PDF) join `GstService`. All three are **auto-deployed on each release tag** via the root `ecosystem.config.js` + `deploy.yml` (`pm2 startOrReload … --update-env`).
- **Unsaved-changes prompt** (issue #18) — leaving the PO create/edit screen with unsaved edits offers **Save as draft / Discard / Stay** (in-app navigation) or the browser's native warning (refresh / close).
- **Crew login on hire** (crewing, feature-flagged) — onboarding, direct placement, and admin crew-create accept an explicit **login email + initial password** for management ranks (`Rank.grantsLogin`), creating the `SITE_STAFF` login in one step.
- **Delivery Locations** (issue #19) — admin-managed `Company`+address list backing the PO "Place of Delivery" dropdown, gated by `manage_delivery_locations` (Manager / SuperUser / Admin).
- **Terms & Conditions catalogue** (issue #11) — admin-managed, user-defined T&C categories + clauses feeding a dynamic PO terms editor; the chosen rows are a JSON snapshot on `PurchaseOrder.terms`.
- **Advance payment on approval** (issue #92) — the approving Manager sets how much is paid first; the resolved absolute amount is stored on `PurchaseOrder.suggestedAdvancePayment` and prefills the first Accounts payment.
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block. - **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number. - **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
- **3-level accounting-code hierarchy**`Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox. - **3-level accounting-code hierarchy**`Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
@ -38,6 +29,4 @@
### Fixed ### Fixed
- **"Email to vendor" never rendered a real PDF** (issue #14) — the auth middleware redirected PdfService's unauthenticated `svc`-token export fetch to `/login` before the route's token check ran, so the bypass never executed. `/api/po/<id>/export` is now allowed through when its `svc` token matches `PDF_SERVICE_TOKEN` (`lib/pdf-export-auth.ts`); everything else stays auth-gated.
- **Reports comparison charts all rendered one colour**`SERIES_COLORS` lived in a `"use client"` module and was imported by the server-component report pages, where a plain value becomes a client-reference proxy (so `SERIES_COLORS[i]` was `undefined` and recharts fell back to its default stroke). Moved the palette to a dependency-free shared module (`lib/report-colors.ts`).
- Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README). - Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README).

View file

@ -1,54 +0,0 @@
# PdfService
Renders a PPMS purchase order to a real **PDF** for the **"Email PO to vendor"**
feature — a standalone **Express + Playwright** microservice, mirroring
`GstService` / `EpfoService`.
The app's `/api/po/:id/export?format=pdf&pdf=1` produces a print-styled HTML page;
PdfService loads that URL in **headless Chromium** and prints it to an A4 PDF. The
export URL carries a short-lived **`svc` token** so the export route serves the
page without a user session (the app's auth middleware allows that one route
through when the token matches — see `App/lib/pdf-export-auth.ts`).
## Endpoints
| Method | Path | Body / Headers | Returns |
|---|---|---|---|
| GET | `/health` | — | `{ status, browser }` |
| POST | `/pdf` | `{ url }` + header `x-pdf-token` | `application/pdf` (else `401` / `400` / `403` / `502`) |
## Security
- **Token** — when `PDF_SERVICE_TOKEN` is set, `/pdf` requires a matching
`x-pdf-token` header (the app and PdfService share the secret).
- **Origin allow-list (anti-SSRF)** — when `ALLOWED_ORIGIN` is set, PdfService
only navigates to URLs whose origin matches it.
- Both unset (dev) → checks are skipped.
## Env
```
PORT=3005
PDF_SERVICE_TOKEN= # shared secret with the app (app side: PDF_SERVICE_TOKEN)
ALLOWED_ORIGIN= # e.g. http://localhost:3000 (optional)
```
## Run
```
npm install
npm run dev # tsx watch src/index.ts
npm run build && npm start # node dist/index.js
```
## App integration
`App/lib/pdf-service.ts` (`renderPoPdf`) POSTs `{ url }` to `/pdf`. The app gates
the feature on `PDF_SERVICE_URL` + `PDF_SERVICE_TOKEN` (`isPdfServiceConfigured()`),
uploads the returned PDF to R2 at a **per-PO key** (reused across sends), and
returns a `mailto:` with a 7-day presigned link. `APP_INTERNAL_URL` is the base URL
PdfService reaches the app at (falls back to `NEXTAUTH_URL`).
On **pms1** the service is auto-deployed on each release tag via the root
`ecosystem.config.js` (pm2 `pdf-service`, port 3005) — see
[Deployment and Operations](https://git.pelagiamarine.com/shad0w/pelagia-portal/wiki/Deployment-and-Operations#microservices).

View file

@ -60,11 +60,6 @@ requires an interactive ESLint migration (a follow-up). Integration tests are
type-checked here but executed against the `pelagia_test` DB by the autofix / locally type-checked here but executed against the `pelagia_test` DB by the autofix / locally
(not in this shared CI, to avoid prod-mirror schema drift). (not in this shared CI, to avoid prod-mirror schema drift).
The **issue watcher pre-applies gate 1 (test-presence) locally** before opening a PR:
if Claude's fix changes code under `App/app|lib|components|hooks` but adds no test, the
watcher does **not** open a PR — it marks the issue `claude-failed` and comments — so it
never raises a PR that this CI would immediately reject. Re-queue (`claude-queue`) to retry.
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist. A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
## Components ## Components
@ -226,11 +221,6 @@ before a release tag deploys them to prod.
- A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by - A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by
`NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing `NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing
when the var is unset, so production is unaffected). when the var is unset, so production is unaffected).
- **Feature flags on staging:** `staging-up.sh` enables
`NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true`, so submitters (TECHNICAL/MANNING) can
read every PO and open the History page here (read-only) for testing ahead of a prod
rollout. The line is appended idempotently, so already-provisioned staging `.env`s pick
it up on the next refresh.
- Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`. - Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`.
## Issue label lifecycle ## Issue label lifecycle

View file

@ -339,22 +339,6 @@ while [ "$f" -lt "$n_fix" ]; do
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count) commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
if [ "$commits" -gt 0 ]; then if [ "$commits" -gt 0 ]; then
# Test-presence gate -- mirror .forgejo/workflows/pr-checks.yml so the watcher
# never opens a PR the CI will immediately reject. "Code" = app source under
# App/(app|lib|components|hooks); tests, prisma, config, docs are exempt.
changed=$(git -C "$WORKDIR" diff --name-only "origin/$BASE_BRANCH...HEAD")
code_changed=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' | grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
test_changed=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
if [ -n "$code_changed" ] && [ -z "$test_changed" ]; then
log "Test-presence gate FAILED for #$num: code changed with no test; not opening a PR"
set_labels "$num" "claude-working" "claude-failed"
add_comment "$num" "$BOT_MARKER
[Claude] Implemented a change but added **no test**, so no PR was opened. The contribution policy (\`pr-checks.yml\`) requires a test for any change under \`App/app|lib|components|hooks\`, and would reject this. Re-add \`claude-queue\` to retry, or pick it up interactively.
Code files that needed an accompanying test:
$code_changed"
continue
fi
log "Claude made $commits commit(s); pushing $branch" log "Claude made $commits commit(s); pushing $branch"
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue

View file

@ -42,20 +42,12 @@ AZURE_AD_CLIENT_SECRET="dev-placeholder"
AZURE_AD_TENANT_ID="dev-placeholder" AZURE_AD_TENANT_ID="dev-placeholder"
DATABASE_URL="$TEST_URL" DATABASE_URL="$TEST_URL"
GST_SERVICE_URL="http://localhost:3003" GST_SERVICE_URL="http://localhost:3003"
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION" NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
PORT=$PORT PORT=$PORT
EOF EOF
chmod 600 "$DIR/App/.env" chmod 600 "$DIR/App/.env"
fi fi
# Ensure feature flags are present on already-provisioned staging envs too (the
# .env above is written only once, so a flag added later won't appear without
# this). Let submitters (TECHNICAL/MANNING) read all POs + open History on staging.
if ! grep -qE '^NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=' "$DIR/App/.env"; then
printf 'NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true\n' >> "$DIR/App/.env"
fi
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port. # pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
# Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel # Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel
# (ssh -L 3200:localhost:3200 ...), never directly from the public internet. # (ssh -L 3200:localhost:3200 ...), never directly from the public internet.