Compare commits

..

1 commit

Author SHA1 Message Date
5764403f1c feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
Reworks the T&C feature per review:
- categories are user-defined DATA, not a fixed enum — admins add new ones;
- ALL PO T&Cs are catalogued, incl. the previously-fixed boilerplate (seeded
  under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
  clause.

- schema: TermsCategory (name/sortOrder/isActive) + TermsCondition (categoryId
  FK + text + isDefault + isActive). PurchaseOrder.terms Json snapshot. Migration
  seeds every standard line as a clause (named slots, the two fixed lines under
  General, empty Others); isDefault rows pre-fill new POs.
- admin /admin/terms: Add/Edit clause form's category is a combobox — typing a
  new name creates the category; isDefault checkbox.
- PO editor components/po/po-terms-editor.tsx: dynamic rows (category + clause
  comboboxes), used by new/edit/manager-edit forms; new POs pre-fill from
  getDefaultPoTerms, edits load po.terms or legacyPoTerms (old tc* + fixed lines).
- storage: PurchaseOrder.terms ([{category,text}]) supersedes tc* for export +
  detail; null on old POs falls back to tc* + fixed lines. parsePoTerms validates.
- export route + po-detail render from terms when present.
- tests rewritten for category creation + catalogue/default helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:08:30 +05:30
94 changed files with 284 additions and 4817 deletions

4
.gitignore vendored
View file

@ -32,10 +32,6 @@ automation/watcher.config.json
automation/logs/
automation/.watcher.lock
# Claude PR-review-comment watcher (real token + lock stay local; shares logs/)
automation/pr-review-watcher.config.json
automation/.pr-review-watcher.lock
# OS
.DS_Store
Thumbs.db

1
App/.gitignore vendored
View file

@ -13,7 +13,6 @@
# Testing
/coverage
/playwright-report
/playwright-report-staging
/test-results
/blob-report

View file

@ -113,14 +113,6 @@ Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a *
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
### Unsaved-changes prompt (issue #18)
The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `<UnsavedChangesGuard>` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and:
- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
- **In-app navigations** (sidebar / header / any internal `<a>`) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**.
`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept).
### PO Numbering (`lib/po-number.ts`)
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (AprMar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
@ -141,33 +133,14 @@ 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.**
**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 (`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.
### Product catalogue sync (`lib/product-catalog.ts`)
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/catalogue/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
### Import → Closed
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
### Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** subheading, so other domains can add report groups later). Gated by **`view_analytics`** (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an **index → drill/detail** pair:
- **Cost Centres** (`/reports/cost-centres`) — spend compared across **vessels** (the PO cost centre). Row → **`/reports/cost-centres/[id]`** detail: trend + a **Top accounting codes** breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N.
- **Accounting Codes** (`/reports/accounting-codes`) — drills the `Account` tree (headings → sub-headings → leaves) via a `?parent=` query; leaf rows open **`/reports/accounting-codes/[id]`**: trend + breakdown **by cost centre** (or, for a non-leaf, by sub-account).
**Spend definition** (`lib/reports.ts`, the pure/unit-tested core): a PO counts once it reaches `POST_APPROVAL_STATUSES`, dated by `approvedAt`, valued at the full `totalAmount` — the same basis as the dashboard tiles. FY is the Indian **AprMar** year. `getReportDataset()` does one query pass; everything else is pure functions over it. **`allocatePoSpend()`** splits each PO across the accounting codes its **line items** carry (line `accountId`, falling back to the PO-level account), **proportionally** so the per-PO rows always sum back to `totalAmount` — so multi-account POs are attributed correctly in the accounting-code report. `poCount` is **distinct POs** (a multi-account PO yields several rows). Account spend rolls leaf descendants up via `buildAccountIndex().leavesUnder`.
**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).
Sites are **not** cost centres (only vessels are).
### Crewing (feature-flagged)
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:

View file

@ -7,7 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
import { distanceKm, formatDistance } from "@/lib/geo";
import { ToggleProductButton, EditProductButton } from "../product-form";
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
import { SiteSelect } from "@/components/inventory/site-select";
import type { Metadata } from "next";

View file

@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
export function ProductsTable({
products,
canManage,
detailBase = "/catalogue/items",
detailBase = "/inventory/items",
}: {
products: ProductRow[];
canManage: boolean;

View file

@ -95,7 +95,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
});
revalidatePath("/admin/vendors");
revalidatePath("/catalogue/vendors");
revalidatePath("/inventory/vendors");
return { ok: true };
}
@ -108,7 +108,7 @@ export async function verifyVendor(vendorId: string): Promise<ActionResult> {
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/catalogue/vendors");
revalidatePath("/inventory/vendors");
revalidatePath(`/admin/vendors/${vendorId}`);
return { ok: true };
}

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2 } from "lucide-react";
import { AdminDialog } from "@/components/ui/admin-dialog";
@ -113,44 +113,6 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
);
}
// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
// handles Escape on the capture phase so closing it does NOT also close the underlying form.
function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
}
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
<h3 className="text-sm font-semibold text-neutral-900">GSTIN CAPTCHA</h3>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
>
</button>
</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
@ -187,19 +149,13 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
});
const data: GstResult & { error?: string } = await res.json();
// Keep the popup open on error so the user sees it in context and can retry / get a new image.
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
setName(data.tradeName || data.legalName);
setAddress(data.address);
if (data.pincode) setPincode(data.pincode);
setGstSuccess(`${data.legalName}${data.status} since ${data.registrationDate}`);
setCaptchaStep("idle");
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
}
// Close the CAPTCHA popup without touching the vendor form fields.
function closeCaptcha() {
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
}
return (
@ -227,46 +183,31 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
{captchaStep === "loading" ? "Loading…" : "Look up"}
</button>
</div>
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
{captchaStep === "loading" ? (
<p className="py-4 text-center text-sm text-neutral-500">Loading CAPTCHA</p>
) : (
<div className="space-y-3">
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
{captchaB64 && (
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
)}
<div className="flex gap-2 items-center">
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
placeholder="6 digits"
disabled={captchaStep === "verifying"}
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
/>
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
</button>
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
New image
</button>
</div>
{gstError && <p className="text-xs text-danger-600">{gstError}</p>}
<div className="flex justify-end border-t border-neutral-100 pt-3">
<button type="button" onClick={closeCaptcha}
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
</div>
{captchaStep === "ready" && captchaB64 && (
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
<div className="flex gap-2 items-center">
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
placeholder="6 digits"
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
/>
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
Verify
</button>
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
New image
</button>
</div>
)}
</CaptchaPopup>
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
{captchaStep === "idle" && gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
</div>
)}
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying</p>}
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
</div>

View file

@ -4,7 +4,6 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { approvePoSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -85,12 +84,6 @@ export async function approvePo({
revalidatePath(`/admin/sites/${siteId}`);
}
// Register the line items in the product catalogue (/catalogue/items) on
// approval, so an approved PO's items are immediately reusable in further POs.
// Idempotent; payment re-syncs to refresh prices on the final figures.
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
revalidatePath("/catalogue/items");
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",

View file

@ -8,7 +8,6 @@ import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
@ -245,7 +244,14 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
<label className={LABEL}>Vendor</label>
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</section>
{/* Line Items */}

View file

@ -19,18 +19,12 @@ const STATUSES = [
interface Props {
vessels: { id: string; name: string }[];
perPageOptions: number[];
defaultPerPage: number;
}
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
export function HistoryFilters({ vessels }: Props) {
const router = useRouter();
const sp = useSearchParams();
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
? Number(sp.get("perPage"))
: defaultPerPage;
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
@ -56,8 +50,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
);
}
// Changing any filter resets to page 1; perPage is preserved across applies.
function buildParams(nextPerPage: number) {
function apply() {
const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
@ -65,24 +58,12 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId);
for (const s of statuses) params.append("status", s);
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
return params;
}
function apply() {
router.push(`/history?${buildParams(perPage).toString()}`);
}
function changePerPage(next: number) {
router.push(`/history?${buildParams(next).toString()}`);
router.push(`/history?${params.toString()}`);
}
function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
const params = new URLSearchParams();
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
const qs = params.toString();
router.push(qs ? `/history?${qs}` : "/history");
router.push("/history");
}
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
@ -158,13 +139,6 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
Clear
</button>
)}
<div className="ml-auto flex items-center gap-2">
<label htmlFor="perPage" className="text-xs font-medium text-neutral-600">Per page</label>
<select id="perPage" value={perPage} onChange={(e) => changePerPage(Number(e.target.value))}
className="rounded-lg border border-neutral-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
{perPageOptions.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
</div>
);

View file

@ -6,16 +6,12 @@ import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters";
import { resolvePagination } from "@/lib/pagination";
import { Suspense } from "react";
import type { Metadata } from "next";
import type { POStatus } from "@prisma/client";
export const metadata: Metadata = { title: "History" };
const PER_PAGE_OPTIONS = [25, 50, 100];
const DEFAULT_PER_PAGE = 25;
interface Props {
searchParams: Promise<{
dateFrom?: string;
@ -24,8 +20,6 @@ interface Props {
approvedTo?: string;
vesselId?: string;
status?: string | string[];
page?: string;
perPage?: string;
}>;
}
@ -42,8 +36,7 @@ export default async function HistoryPage({ searchParams }: Props) {
redirect("/dashboard");
}
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
await searchParams;
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) {
@ -70,45 +63,16 @@ export default async function HistoryPage({ searchParams }: Props) {
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const total = await db.purchaseOrder.count({ where });
const { perPage, page, totalPages, skip, take } = resolvePagination({
perPageParam,
pageParam,
total,
options: PER_PAGE_OPTIONS,
defaultPerPage: DEFAULT_PER_PAGE,
});
const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { createdAt: "desc" },
skip,
take,
take: 200,
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Shared filter params for the pagination footer links (everything except `page`).
const pageParams = new URLSearchParams();
if (dateFrom) pageParams.set("dateFrom", dateFrom);
if (dateTo) pageParams.set("dateTo", dateTo);
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
if (approvedTo) pageParams.set("approvedTo", approvedTo);
if (vesselId) pageParams.set("vesselId", vesselId);
for (const s of statuses) pageParams.append("status", s);
pageParams.set("perPage", String(perPage));
const pageHref = (p: number) => {
const params = new URLSearchParams(pageParams);
params.set("page", String(p));
return `/history?${params.toString()}`;
};
const firstRow = total === 0 ? 0 : skip + 1;
const lastRow = skip + orders.length;
const exportParams = new URLSearchParams({ format: "csv" });
if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo);
@ -140,7 +104,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div>
<Suspense>
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
<HistoryFilters vessels={vessels} />
</Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -185,41 +149,8 @@ export default async function HistoryPage({ searchParams }: Props) {
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
)}
</div>
{total > 0 && (
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
<span>
Showing {firstRow}{lastRow} of {total}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={pageHref(page - 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Previous
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Previous
</span>
)}
<span className="text-neutral-500">
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link
href={pageHref(page + 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Next
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Next
</span>
)}
</div>
</div>
{orders.length === 200 && (
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results refine filters to narrow results.</p>
)}
</div>
);

View file

@ -46,8 +46,8 @@ export function CartView() {
<p className="text-neutral-500 font-medium">Your cart is empty</p>
<p className="text-sm text-neutral-400 mt-1 mb-6">Browse Items or Vendors to add line items</p>
<div className="flex gap-3 justify-center">
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
<Link href="/catalogue/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
<Link href="/inventory/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
</div>
</div>
);
@ -108,7 +108,7 @@ export function CartView() {
<div className="flex items-center justify-between">
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
<div className="flex gap-3">
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
+ Add more items
</Link>
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">

View file

@ -26,7 +26,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
const { id } = await params;
const { site: siteId } = await searchParams;
const baseHref = `/catalogue/items/${id}`;
const baseHref = `/inventory/items/${id}`;
const [product, sites] = await Promise.all([
db.product.findUnique({
@ -85,7 +85,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
<div className="max-w-6xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/catalogue/items" className="hover:text-neutral-700">Items</Link>
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{product.name}</span>
</div>

View file

@ -108,7 +108,7 @@ export function ItemsTable({
value={currentSiteId ?? ""}
onChange={(e) => {
const id = e.target.value;
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/items");
}}
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
>
@ -254,7 +254,7 @@ export function ItemsTable({
<td className="px-12 py-2.5">
<div className="flex items-center gap-2">
<Link
href={`/catalogue/vendors/${vendor.vendorId}`}
href={`/inventory/vendors/${vendor.vendorId}`}
onClick={(e) => e.stopPropagation()}
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
>

View file

@ -20,7 +20,7 @@ export default async function InventoryItemsPage() {
},
});
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/items
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
const canManage = hasPermission(session.user.role, "manage_products");
return (

View file

@ -48,7 +48,7 @@ export default async function InventoryVendorDetailPage({ params }: Props) {
<div className="max-w-5xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/catalogue/vendors" className="hover:text-neutral-700">Vendors</Link>
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{vendor.name}</span>
</div>

View file

@ -68,7 +68,7 @@ export function VendorsTable({
value={currentSiteId ?? ""}
onChange={(e) => {
const id = e.target.value;
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
router.push(id ? `/inventory/vendors?siteId=${id}` : "/inventory/vendors");
}}
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
>
@ -149,7 +149,7 @@ export function VendorsTable({
<tr key={vendor.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
{vendor.name}
</Link>
{vendor.vendorId && (

View file

@ -4,12 +4,107 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { processPaymentSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
// Sync product catalog after payment is confirmed:
// - Auto-create products for unlinked line items (matched by name or brand new)
// - Upsert per-vendor prices for all items
async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
const session = await auth();

View file

@ -7,10 +7,8 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
@ -70,8 +68,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED";
@ -99,7 +95,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
setError(result.error);
setSubmitting(null);
} else {
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
}
@ -120,7 +115,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const extPo = po;
return (
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{canResubmit && (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
<p className="text-sm font-medium text-warning-700">
@ -184,7 +179,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
onChange={setDefaultAccountId}
groups={accounts}
placeholder="Search accounting code…"
required
@ -250,7 +245,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor
items={lineItems}
onChange={(v) => { setLineItems(v); markDirty(); }}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
@ -260,14 +255,21 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
{/* Vendor */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</section>
{/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
</section>
{error && (
@ -296,12 +298,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
</button>
)}
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("save")}
saving={submitting === "save"}
/>
</form>
);
}

View file

@ -2,7 +2,7 @@
import { auth } from "@/auth";
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";
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." };
}
// Render → store → presigned link. The PDF is cached at a deterministic
// 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.
// Render → store → presigned link.
let link: string;
try {
const pdf = await renderPoPdf(poId);
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);
await uploadBuffer(key, pdf, "application/pdf");
}
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
await uploadBuffer(key, pdf, "application/pdf");
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
} catch (e) {
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };

View file

@ -140,7 +140,7 @@ export async function confirmReceipt({
if (newStatus === "CLOSED" && po.vendorId) {
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/catalogue/vendors");
revalidatePath("/inventory/vendors");
}
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });

View file

@ -189,7 +189,7 @@ export async function importPo(
if (resolvedVendorId) {
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/catalogue/vendors");
revalidatePath("/inventory/vendors");
}
revalidatePath("/history");

View file

@ -7,10 +7,8 @@ import type { Vendor } from "@prisma/client";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po";
@ -43,14 +41,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
);
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
async function handleSubmit(intent: "draft" | "submit") {
setSubmitting(intent);
@ -85,12 +82,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
return;
}
}
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
return (
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
{/* Order Information */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
@ -147,7 +143,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
onChange={setDefaultAccountId}
groups={accounts}
placeholder="Search accounting code…"
required
@ -219,7 +215,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor
items={lineItems}
onChange={(v) => { setLineItems(v); markDirty(); }}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
@ -233,7 +229,19 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Vendor (optional can be added later)
</label>
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
<select
name="vendorId"
value={vendorId}
onChange={(e) => setVendorId(e.target.value)}
className={INPUT_CLS}
>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
</div>
</section>
@ -241,13 +249,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration Terms &amp; Conditions.</p>
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
</section>
{/* Attachments */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
</section>
{error && (
@ -272,12 +280,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
</button>
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("draft")}
saving={submitting === "draft"}
/>
</form>
);
}

View file

@ -1,194 +0,0 @@
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
accountNodeSpend,
accountNodeWeekly,
costCentresForAccount,
childBreakdown,
parseGranularity,
resolveFy,
resolveMonth,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Accounting Code — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const tierBadgeCls: Record<string, string> = {
Heading: "bg-primary-50 text-primary-700",
"Sub-heading": "bg-violet-50 text-violet-700",
Leaf: "bg-neutral-100 text-neutral-600",
};
export default async function AccountingCodeDetail({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ fy?: string; gran?: string; month?: string; break?: string; topn?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const { id } = await params;
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const node = idx.byId.get(id);
if (!node) notFound();
const gran = parseGranularity(sp.gran);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const unit = yearly ? "year" : weekly ? "week" : "month";
const leaf = idx.isLeaf(id);
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
const breakMode = leaf ? "cc" : sp.break === "cc" ? "cc" : "children";
const spend = accountNodeSpend(ds, idx, id, fy);
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const series = yearly
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: spend.fyTotals[i] }))
: weekly
? WEEK_LABELS.map((w, i) => ({ label: w, value: accountNodeWeekly(ds, idx, id, fy, month)[i] }))
: FY_MONTHS.map((m, i) => ({ label: m, value: spend.months[i] }));
const total = sum(series.map((s) => s.value));
const avg = series.length ? total / series.length : 0;
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
const nf = ds.fys.length;
const yoy = nf >= 2 && spend.fyTotals[nf - 2] ? ((spend.fyTotals[nf - 1] - spend.fyTotals[nf - 2]) / spend.fyTotals[nf - 2]) * 100 : 0;
const childTier = idx.childrenOf(id)[0]?.tier ?? "Sub-heading";
const breakdown = (breakMode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy)).slice(0, topn);
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
const breakLabel = breakMode === "cc" ? "Cost centre" : childTier;
const breakTitle = breakMode === "cc" ? "Top cost centres" : "Composition by sub-account";
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base = `/reports/accounting-codes/${id}`;
const q = (extra: Record<string, string>) => {
const p = new URLSearchParams({ fy: String(fy), gran });
if (weekly) p.set("month", String(month));
for (const [k, v] of Object.entries(extra)) p.set(k, v);
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
const path = idx.pathTo(id);
const trail = [
{ label: "Accounting Codes", href: `/reports/accounting-codes?fy=${fy}&gran=${gran}` },
...path.map((a, i) => ({
label: `${a.code} · ${a.name}`,
href: i < path.length - 1 ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${a.id}` : undefined,
})),
];
return (
<div>
<ReportBreadcrumb trail={trail} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?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"
>
Back to Accounting Codes
</Link>
<ReportTitle
title={`${node.code} · ${node.name}`}
subtitle={`Aggregates all spend under this ${node.tier.toLowerCase()} · ${periodLabel}`}
badge={<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tierBadgeCls[node.tier]}`}>{node.tier}</span>}
/>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-neutral-900">{breakTitle}</p>
<div className="flex flex-wrap items-center gap-3">
{!leaf && (
<SegLink
label="Break down by"
options={[{ value: "children", label: `${childTier}s` }, { value: "cc", label: "Cost centres" }]}
current={breakMode}
hrefFor={(v) => q({ break: v, topn: sp.topn ?? "5" })}
/>
)}
<SegLink
label="Top"
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
hrefFor={(v) => q({ break: breakMode, topn: v })}
/>
</div>
</div>
{breakdown.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">No spend to break down for {periodLabel}.</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<BreakdownChart data={breakdown} />
</div>
<div className="lg:col-span-2">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="py-2">{breakLabel}</th>
<th className="py-2 text-right">Spend</th>
<th className="py-2 text-right">%</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{breakdown.map((b, i) => (
<tr key={b.id}>
<td className="py-2">
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{b.label}
</td>
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,220 +0,0 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight, BarChart3 } from "lucide-react";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
accountLevelRows,
accountNodeSpend,
accountNodeWeekly,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
SCOPE_LABELS,
type NodeSpend,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Accounting Codes — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const tierBadgeCls: Record<string, string> = {
Heading: "bg-primary-50 text-primary-700",
"Sub-heading": "bg-violet-50 text-violet-700",
Leaf: "bg-neutral-100 text-neutral-600",
};
export default async function AccountingCodesReport({
searchParams,
}: {
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; parent?: string; sel?: string; cmp?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.gran);
const scope = parseScope(sp.scope);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const sel = parseSel(sp.sel);
const cmp = sp.cmp === "1" && sel.length > 0;
const parent = sp.parent && idx.byId.has(sp.parent) ? sp.parent : null;
const parentNode = parent ? idx.byId.get(parent)! : null;
const rankOf = (r: NodeSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
const sparkOf = (r: NodeSpend) => (yearly ? r.fyTotals : weekly ? accountNodeWeekly(ds, idx, r.node.id, fy, month) : r.months);
const ranked = cmp
? sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }))
: accountLevelRows(ds, idx, parent, fy);
ranked.sort((a, b) => rankOf(b) - rankOf(a));
const shown = cmp ? ranked : applyScope(ranked, scope);
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
const childTier = shown[0]?.node.tier ?? "Heading";
const top = shown[0];
const nf = ds.fys.length;
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
// One distinct colour per accounting code (series) in every granularity; the
// x-axis is months / weeks / financial years.
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
const row: Record<string, string | number> = { x: lab };
shown.forEach((r) => (row[r.node.code] = sparkOf(r)[i]));
return row;
});
const series: Series[] = shown.map((r, i) => ({ key: r.node.code, color: colored(i) }));
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base: Record<string, string | undefined> = {
fy: String(fy),
gran: gran === "monthly" ? undefined : gran,
scope: scope === "top5" ? undefined : scope,
month: weekly ? String(month) : undefined,
};
const qs = (extra: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...base, ...extra })) if (v) p.set(k, v);
const s = p.toString();
return s ? `?${s}` : "";
};
const linkWith = (parentId: string | null) => `/reports/accounting-codes${qs({ parent: parentId ?? undefined, sel: sel.join(",") || undefined })}`;
const detailHref = (id: string) => `/reports/accounting-codes/${id}${qs({ scope: undefined, parent: undefined })}`;
const selHref = (id: string) => {
const next = toggleSel(sel, id);
return `/reports/accounting-codes${qs({ parent: cmp ? undefined : parent ?? undefined, sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
};
const rowHref = (r: NodeSpend) => (idx.isLeaf(r.node.id) ? detailHref(r.node.id) : linkWith(r.node.id));
const exportHref = `/api/reports/spend?dim=accounting-code&fy=${fy}&gran=${gran}&scope=${scope}${parent && !cmp ? `&parent=${parent}` : ""}${cmp ? `&sel=${sel.join(",")}` : ""}`;
const trail = [{ label: "Accounting Codes", href: parent || cmp ? linkWith(null) : undefined }];
if (parentNode && !cmp) {
idx.pathTo(parentNode.id).forEach((a, i, arr) => trail.push({ label: `${a.code} · ${a.name}`, href: i < arr.length - 1 ? linkWith(a.id) : undefined }));
}
return (
<div>
<ReportBreadcrumb trail={trail} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
scope={cmp ? undefined : scope}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
{cmp ? (
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to browse
</Link>
) : (
<>
{parentNode && (
<Link
href={linkWith(parentNode.parentId ?? null)}
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"}
</Link>
)}
{sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ parent: parent ?? undefined })} />}
</>
)}
<ReportTitle
title={cmp ? "Custom comparison" : parentNode ? `${parentNode.code} · ${parentNode.name}` : "Accounting Codes"}
subtitle={
cmp
? `Comparing ${shown.length} selected accounting codes. Untick a row to remove it.`
: parentNode
? `Comparing the ${childTier.toLowerCase()}s of ${parentNode.name}. Tick to graph, or click to ${childTier === "Leaf" ? "open its report" : "drill deeper"}.`
: "Comparing top-level headings. Tick to graph, or click a heading to drill in."
}
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
<Kpi label={cmp ? "Selected" : `${childTier}s`} value={String(shown.length)} sub={cmp ? "in this graph" : `${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top ? top.node.code : "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? `Spend by ${childTier.toLowerCase()} — year over year` : weekly ? `Weekly spend by ${childTier.toLowerCase()}` : `Monthly spend by ${childTier.toLowerCase()}`}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
const leaf = idx.isLeaf(r.node.id);
const inner = (
<>
<span className="w-14 shrink-0 font-mono text-xs text-neutral-500">{r.node.code}</span>
<span className="flex-1 truncate text-sm font-medium text-neutral-900 group-hover:text-primary-700">{r.node.name}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${tierBadgeCls[r.node.tier]}`}>{r.node.tier}</span>
<Sparkline values={sparkOf(r)} width={80} height={24} />
<span className="w-28 text-right font-medium tabular-nums text-sm">{formatCurrency(value)}</span>
<div className="hidden w-12 text-right tabular-nums text-xs text-neutral-500 md:block">{pct.toFixed(0)}%</div>
{!cmp && (leaf ? <BarChart3 className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" /> : <ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />)}
</>
);
return (
<div key={r.node.id} className="group flex items-center gap-3 border-b border-neutral-100 px-5 py-3 last:border-0 hover:bg-primary-50/40">
<SelectCheckbox checked={sel.includes(r.node.id)} href={selHref(r.node.id)} />
{cmp ? (
<div className="flex flex-1 items-center gap-3 min-w-0">{inner}</div>
) : (
<Link href={rowHref(r)} className="flex flex-1 items-center gap-3 min-w-0">{inner}</Link>
)}
</div>
);
})}
</div>
</>
)}
</div>
);
}

View file

@ -1,162 +0,0 @@
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
topAccountsForCostCentre,
parseGranularity,
resolveFy,
resolveMonth,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
type Tier,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Cost Centre — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const TIERS: Tier[] = ["Heading", "Sub-heading", "Leaf"];
export default async function CostCentreDetail({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ fy?: string; gran?: string; month?: string; tier?: string; topn?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const { id } = await params;
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.gran);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const unit = yearly ? "year" : weekly ? "week" : "month";
const tier: Tier = TIERS.includes(sp.tier as Tier) ? (sp.tier as Tier) : "Leaf";
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
const row = costCentreRows(ds, fy).find((r) => r.id === id);
if (!row) notFound();
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const series = yearly
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: row.fyTotals[i] }))
: weekly
? WEEK_LABELS.map((w, i) => ({ label: w, value: costCentreWeekly(ds, id, fy, month)[i] }))
: FY_MONTHS.map((m, i) => ({ label: m, value: row.months[i] }));
const total = sum(series.map((s) => s.value));
const avg = series.length ? total / series.length : 0;
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
const nf = ds.fys.length;
const yoy = nf >= 2 && row.fyTotals[nf - 2] ? ((row.fyTotals[nf - 1] - row.fyTotals[nf - 2]) / row.fyTotals[nf - 2]) * 100 : 0;
const breakdown = topAccountsForCostCentre(ds, idx, id, fy, tier).slice(0, topn);
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base = `/reports/cost-centres/${id}`;
const q = (extra: Record<string, string>) => {
const p = new URLSearchParams({ fy: String(fy), gran });
if (weekly) p.set("month", String(month));
for (const [k, v] of Object.entries(extra)) p.set(k, v);
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres", href: `/reports/cost-centres?fy=${fy}&gran=${gran}` }, { label: row.name }]} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
<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">
Back to Cost Centres
</Link>
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-neutral-900">Top accounting codes</p>
<div className="flex flex-wrap items-center gap-3">
<SegLink label="Tier" options={TIERS.map((t) => ({ value: t, label: t }))} current={tier} hrefFor={(v) => q({ tier: v, topn: sp.topn ?? "5" })} />
<SegLink
label="Top"
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
hrefFor={(v) => q({ tier, topn: v })}
/>
</div>
</div>
{breakdown.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">No spend at this tier for {periodLabel}.</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<BreakdownChart data={breakdown} />
</div>
<div className="lg:col-span-2">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="py-2">{tier}</th>
<th className="py-2 text-right">Spend</th>
<th className="py-2 text-right">%</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{breakdown.map((b, i) => (
<tr key={b.id}>
<td className="py-2">
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{b.label}
</td>
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1,211 +0,0 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight } from "lucide-react";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
costCentreRows,
costCentreWeekly,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
SCOPE_LABELS,
type CostCentreSpend,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Cost Centres — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
export default async function CostCentresReport({
searchParams,
}: {
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; sel?: string; cmp?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const sp = await searchParams;
const ds = await getReportDataset();
const gran = parseGranularity(sp.gran);
const scope = parseScope(sp.scope);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const sel = parseSel(sp.sel);
const cmp = sp.cmp === "1" && sel.length > 0;
const ranked = costCentreRows(ds, fy);
const rankOf = (r: CostCentreSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
ranked.sort((a, b) => rankOf(b) - rankOf(a));
const shown = cmp ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
const top = shown[0];
const sparkOf = (r: CostCentreSpend) => (yearly ? r.fyTotals : weekly ? costCentreWeekly(ds, r.id, fy, month) : r.months);
const nf = ds.fys.length;
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
// Chart data — one distinct colour per item (series) in every granularity; the
// x-axis is months / weeks / financial years. (Yearly is grouped bars per item,
// not per FY, so each cost centre keeps its own colour.)
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
const row: Record<string, string | number> = { x: lab };
shown.forEach((r) => (row[r.name] = sparkOf(r)[i]));
return row;
});
const series: Series[] = shown.map((r, i) => ({ key: r.name, color: colored(i) }));
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
// Query-string helpers (preserve current filters).
const baseParams: Record<string, string | undefined> = {
fy: String(fy),
gran: gran === "monthly" ? undefined : gran,
scope: scope === "top5" ? undefined : scope,
month: weekly ? String(month) : undefined,
};
const qs = (extra: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...baseParams, ...extra })) if (v) p.set(k, v);
const s = p.toString();
return s ? `?${s}` : "";
};
const selHref = (id: string) => {
const next = toggleSel(sel, id);
return `/reports/cost-centres${qs({ sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
};
const detailHref = (id: string) => `/reports/cost-centres/${id}${qs({ scope: undefined })}`;
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}${cmp ? `&sel=${sel.join(",")}` : ""}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
scope={cmp ? undefined : scope}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
{cmp ? (
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to browse
</Link>
) : (
sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ sel: undefined, cmp: undefined })} />
)}
<ReportTitle
title={cmp ? "Custom comparison" : "Cost Centres"}
subtitle={
cmp
? `Comparing ${shown.length} selected cost centres. Untick a row to remove it.`
: "Approved spend compared across cost centres (vessels). Tick rows to graph together, or click a row for its report."
}
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
<Kpi label="Cost centres" value={String(shown.length)} sub={cmp ? "selected" : `${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top?.name ?? "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "Monthly spend by cost centre"}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="px-5 py-3">Cost Centre</th>
<th className="px-5 py-3">Trend</th>
<th className="px-5 py-3 text-right">Total Spend</th>
<th className="px-5 py-3 text-right">% of Shown</th>
<th className="px-5 py-3 text-right">POs</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
return (
<tr key={r.id} className="group hover:bg-primary-50/40">
<td className="px-5 py-3">
<div className="flex items-center gap-3">
<SelectCheckbox checked={sel.includes(r.id)} href={selHref(r.id)} />
<Link href={detailHref(r.id)} className="block font-medium text-neutral-900 group-hover:text-primary-700">
{r.name}
<span className="ml-2 text-xs font-normal text-neutral-400">{r.code}</span>
</Link>
</div>
</td>
<td className="px-5 py-3">
<Sparkline values={sparkOf(r)} />
</td>
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100">
<div className="h-full rounded-full bg-primary-600" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className="w-10 text-right tabular-nums text-neutral-500">{pct.toFixed(0)}%</span>
</div>
</td>
<td className="px-5 py-3 text-right tabular-nums text-neutral-500">{r.poCount}</td>
<td className="px-5 py-3 text-right">
<Link href={detailHref(r.id)}>
<ChevronRight className="inline h-4 w-4 text-neutral-300 group-hover:text-primary-500" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
);
}

View file

@ -1,97 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import {
getReportDataset,
buildAccountIndex,
costCentreRows,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
accountNodeSpend,
applyScope,
parseScope,
parseGranularity,
parseSel,
resolveFy,
fyLabel,
type Tier,
} from "@/lib/reports";
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const cell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
function csv(headers: string[], rows: (string | number)[][]): string {
return [headers, ...rows].map((r) => r.map(cell).join(",")).join("\n");
}
function file(name: string, body: string) {
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${name}-${Date.now()}.csv"`,
},
});
}
// CSV export for the Reports → Purchasing views. The `dim` query param mirrors
// the page the user is on, so the download matches what's on screen.
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "view_analytics")) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const dim = sp.get("dim") ?? "cost-centre";
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.get("gran") ?? undefined);
const scope = parseScope(sp.get("scope") ?? undefined);
const fy = resolveFy(ds, sp.get("fy") ?? undefined);
const yearly = gran === "yearly";
const fyCols = ds.fys.map(fyLabel);
const sel = parseSel(sp.get("sel") ?? undefined);
if (dim === "cost-centre") {
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const picked = sel.length ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
const rows = picked.map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-cost-centre-spend", csv(["Code", "Cost Centre", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "accounting-code") {
let ranked;
if (sel.length) {
ranked = sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }));
} else {
const parent = sp.get("parent");
const parentId = parent && idx.byId.has(parent) ? parent : null;
ranked = accountLevelRows(ds, idx, parentId, fy);
}
ranked.sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const picked = sel.length ? ranked : applyScope(ranked, scope);
const rows = picked.map((r) => [r.node.code, r.node.name, r.node.tier, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-accounting-code-spend", csv(["Code", "Name", "Tier", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "cost-centre-detail") {
const id = sp.get("id") ?? "";
const tier = (["Heading", "Sub-heading", "Leaf"] as Tier[]).includes(sp.get("tier") as Tier) ? (sp.get("tier") as Tier) : "Leaf";
const rows = topAccountsForCostCentre(ds, idx, id, fy, tier).map((b) => [b.label, b.value]);
return file("pelagia-cost-centre-detail", csv([tier, `Spend (${fyLabel(fy)})`], rows));
}
if (dim === "accounting-code-detail") {
const id = sp.get("id") ?? "";
const leaf = idx.isLeaf(id);
const mode = leaf || sp.get("break") === "cc" ? "cc" : "children";
const bd = mode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy);
const rows = bd.map((b) => [b.label, b.value]);
return file("pelagia-accounting-code-detail", csv([mode === "cc" ? "Cost centre" : "Sub-account", `Spend (${fyLabel(fy)})`], rows));
}
return NextResponse.json({ error: "Unknown report dimension" }, { status: 400 });
}

View file

@ -56,50 +56,36 @@ const HISTORY_ROLES: Role[] = [
const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
{ href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
{ href: "/profile", label: "My Profile", icon: UserCircle },
];
// ── Purchasing section ────────────────────────────────────────────────────────
// Purchase Order actions (create / browse / import / history)
const PURCHASING_PO: NavItem[] = [
{ href: "/po/new", label: "New Purchase Order", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import Purchase Order", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/history", label: "Purchase Order History", icon: History, roles: HISTORY_ROLES },
];
// Staff browsing items (product catalogue + cart for PO creation)
const PURCHASING_STAFF: NavItem[] = [
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
];
// Manager catalogue management — Sites conditionally shown
// Admin does not use Purchasing; their links live under Administration
const PURCHASING_MGMT: NavItem[] = [
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
...(INVENTORY_ENABLED
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
: []),
];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Reports section ───────────────────────────────────────────────────────────
// Spend analytics, gated by `view_analytics` (Manager / SuperUser / Auditor /
// Admin). Links are grouped under a "Purchasing" subheading so other domains
// (e.g. Crewing) can hang their own report groups here later.
const REPORTS_ROLES: Role[] = ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const REPORTS_PURCHASING: NavItem[] = [
{ href: "/reports/cost-centres", label: "Cost Centres", icon: Ship, roles: REPORTS_ROLES },
{ href: "/reports/accounting-codes", label: "Accounting Codes", icon: Building2, roles: REPORTS_ROLES },
];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
@ -144,14 +130,10 @@ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
];
interface NavGroup {
label?: string; // optional subheading shown above the group's links
items: NavItem[];
}
interface Section {
id: string;
label: string;
groups: NavGroup[];
items: NavItem[];
}
function isItemActive(href: string, pathname: string) {
@ -162,29 +144,22 @@ export function Sidebar({ userRole }: { userRole: Role }) {
const pathname = usePathname();
const isAdmin = userRole === "ADMIN";
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole);
const visibleMain = NAV_ITEMS.filter(visible);
const visiblePurchasing = PURCHASING_ITEMS.filter(visible);
const visibleReports = REPORTS_PURCHASING.filter(visible);
const visibleCrewing = CREWING_ITEMS.filter(visible);
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter(visible);
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
// Headed, collapsible sections (the main links above sit outside any section).
// A section holds one or more groups; a group can carry an optional subheading.
const sections: Section[] = [
{ id: "purchasing", label: "Purchasing", groups: [{ items: visiblePurchasing }] },
{ id: "reports", label: "Reports", groups: [{ label: "Purchasing", items: visibleReports }] },
{ id: "crewing", label: "Crewing", groups: [{ items: visibleCrewing }] },
{ id: "administration", label: "Administration", groups: [{ items: adminItems }] },
]
.map((s) => ({ ...s, groups: s.groups.filter((g) => g.items.length > 0) }))
.filter((s) => s.groups.length > 0);
{ id: "purchasing", label: "Purchasing", items: visiblePurchasing },
{ id: "crewing", label: "Crewing", items: visibleCrewing },
{ id: "administration", label: "Administration", items: adminItems },
].filter((s) => s.items.length > 0);
const sectionItems = (s: Section) => s.groups.flatMap((g) => g.items);
// The section (if any) that holds the currently active route.
const activeSectionId =
sections.find((s) => sectionItems(s).some((i) => isItemActive(i.href, pathname)))?.id ?? null;
sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null;
// Single-open accordion, collapsed by default. Auto-expand the section that
// contains the active route so the user is never stranded on a hidden link.
@ -226,17 +201,8 @@ export function Sidebar({ userRole }: { userRole: Role }) {
/>
{isOpen && (
<div id={regionId} className="space-y-0.5">
{section.groups.map((group, gi) => (
<div key={group.label ?? gi} className="space-y-0.5">
{group.label && (
<p className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-300">
{group.label}
</p>
)}
{group.items.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</div>
{section.items.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</div>
)}

View file

@ -1,121 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
interface Props {
/** Arm the guard — true once the form has unsaved changes. */
enabled: boolean;
/** Persist the in-progress PO as a draft. Should navigate away on success. */
onSaveDraft: () => void;
/** True while the draft save is in flight (drives the button label/disable). */
saving: boolean;
}
// Warns the user before they leave a PO form with unsaved changes (issue #18).
// Two paths are covered:
// • Hard navigations (refresh, tab close, external links) → the browser's own
// "Leave site?" prompt (browsers can't render custom buttons here, so the
// save-as-draft option isn't offered on this path).
// • In-app navigations (sidebar / header / any internal <a>) → intercepted and
// replaced with our own modal offering Save as draft / Discard / Stay.
export function UnsavedChangesGuard({ enabled, onSaveDraft, saving }: Props) {
const router = useRouter();
const [pendingHref, setPendingHref] = useState<string | null>(null);
// Listeners are attached once; read `enabled` through a ref so they always see
// the latest value without re-binding on every keystroke.
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (!enabledRef.current) return;
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onClick(e: MouseEvent) {
if (!enabledRef.current || e.defaultPrevented) return;
// Ignore non-primary clicks and modifier-clicks (new tab / download etc.).
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const anchor = (e.target as HTMLElement | null)?.closest("a");
const href = anchor?.getAttribute("href");
if (!anchor || !href || href.startsWith("#")) return;
if (anchor.hasAttribute("download")) return;
if (anchor.target && anchor.target !== "_self") return;
const url = new URL(href, window.location.href);
// External origin → let the browser's beforeunload prompt handle it.
if (url.origin !== window.location.origin) return;
// Same page (e.g. a no-op link) → nothing to guard.
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
setPendingHref(url.pathname + url.search + url.hash);
}
// Capture phase so we run before Next's <Link> click handler.
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, []);
const discard = useCallback(() => {
const href = pendingHref;
setPendingHref(null);
enabledRef.current = false; // let this navigation through
if (href) router.push(href);
}, [pendingHref, router]);
const stay = useCallback(() => {
if (saving) return;
setPendingHref(null);
}, [saving]);
function saveDraft() {
// Close the prompt so any inline save error is visible; the save action
// navigates to the PO on success.
setPendingHref(null);
onSaveDraft();
}
return (
<AdminDialog title="Unsaved changes" open={pendingHref !== null} onClose={stay}>
<div className="space-y-4">
<p className="text-sm text-neutral-600">
You have unsaved changes on this purchase order. Save it as a draft before leaving, or discard your changes?
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={stay}
disabled={saving}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 sm:order-1"
>
Stay on page
</button>
<button
type="button"
onClick={discard}
disabled={saving}
className="rounded-lg border border-danger-200 bg-white px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60 sm:order-2"
>
Discard changes
</button>
<button
type="button"
onClick={saveDraft}
disabled={saving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors sm:order-3"
>
{saving ? "Saving…" : "Save as draft"}
</button>
</div>
</div>
</AdminDialog>
);
}

View file

@ -1,150 +0,0 @@
"use client";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
Cell,
} from "recharts";
import { SERIES_COLORS } from "@/lib/report-colors";
// Re-exported for back-compat; new server-component code should import the
// palette from "@/lib/report-colors" directly (see that file for why).
export { SERIES_COLORS };
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
export function formatINRShort(n: number): string {
const a = Math.abs(n);
if (a >= 1_00_00_000) return `${(n / 1_00_00_000).toFixed(1)}Cr`;
if (a >= 1_00_000) return `${(n / 1_00_000).toFixed(1)}L`;
if (a >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n.toFixed(0)}`;
}
function fullINR(n: number): string {
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
}
export interface Series {
key: string;
color: string;
}
interface ComparisonProps {
kind: "lines" | "bars";
data: Record<string, string | number>[];
xKey: string;
series: Series[];
height?: number;
}
/** Multi-series comparison: monthly trend lines, or year-over-year grouped bars. */
export function ComparisonChart({ kind, data, xKey, series, height = 340 }: ComparisonProps) {
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
return (
<ResponsiveContainer width="100%" height={height}>
{kind === "lines" ? (
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey={xKey} {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} />
<Legend wrapperStyle={{ fontSize: 11 }} iconType="plainline" />
{series.map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 5 }} />
))}
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey={xKey} {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{series.map((s) => (
<Bar key={s.key} dataKey={s.key} fill={s.color} radius={[3, 3, 0, 0]} />
))}
</BarChart>
)}
</ResponsiveContainer>
);
}
interface TrendProps {
kind: "line" | "bar";
data: { label: string; value: number }[];
height?: number;
}
/** Single-series spend trend (monthly line or yearly bar). */
export function TrendChart({ kind, data, height = 300 }: TrendProps) {
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
return (
<ResponsiveContainer width="100%" height={height}>
{kind === "line" ? (
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="label" {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} />
<Line type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} fill="rgba(37,99,235,0.08)" />
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="label" {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} />
</BarChart>
)}
</ResponsiveContainer>
);
}
/** Horizontal top-N breakdown bars (each bar its own colour). */
export function BreakdownChart({ data, height = 300 }: { data: { label: string; value: number }[]; height?: number }) {
const trimmed = data.map((d) => ({ ...d, short: d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label }));
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart layout="vertical" data={trimmed} margin={{ top: 4, right: 16, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
<XAxis type="number" tickFormatter={formatINRShort} tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="short" width={140} tick={{ fontSize: 11, fill: "#525252" }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{trimmed.map((_, i) => (
<Cell key={i} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
/** Tiny inline trend sparkline (plain SVG — no chart library needed per row). */
export function Sparkline({ values, width = 90, height = 28 }: { values: number[]; width?: number; height?: number }) {
if (values.length < 2) return <svg width={width} height={height} />;
const max = Math.max(...values);
const min = Math.min(...values);
const pad = 3;
const span = max - min || 1;
const pts = values.map((v, i) => {
const x = pad + (i / (values.length - 1)) * (width - 2 * pad);
const y = height - pad - ((v - min) / span) * (height - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const last = pts[pts.length - 1].split(",");
return (
<svg width={width} height={height} className="overflow-visible">
<polyline points={pts.join(" ")} fill="none" stroke="#2563eb" strokeWidth={1.5} />
<circle cx={last[0]} cy={last[1]} r={2} fill="#2563eb" />
</svg>
);
}

View file

@ -1,28 +0,0 @@
import { cn } from "@/lib/utils";
// Presentational KPI tile (server component — no interactivity). `delta` colours
// the sub-line green/red for positive/negative changes (e.g. YoY).
export function Kpi({
label,
value,
sub,
delta,
}: {
label: string;
value: string;
sub?: string;
delta?: number;
}) {
const subColor = delta === undefined ? "text-neutral-400" : delta >= 0 ? "text-green-600" : "text-red-600";
return (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">{label}</p>
<p className="mt-1.5 text-xl font-semibold text-neutral-900">{value}</p>
<p className={cn("mt-0.5 text-xs", subColor)}>{sub ?? " "}</p>
</div>
);
}
export function KpiStrip({ children }: { children: React.ReactNode }) {
return <div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">{children}</div>;
}

View file

@ -1,103 +0,0 @@
import Link from "next/link";
import { ChevronRight, Check } from "lucide-react";
// Reports breadcrumb: always rooted at "Reports", then the section and any
// drill/detail crumbs. A crumb with an href is a link; the last is the current.
export function ReportBreadcrumb({ trail }: { trail: { label: string; href?: string }[] }) {
return (
<nav className="mb-4 flex flex-wrap items-center gap-2 text-sm text-neutral-500">
<span>Reports</span>
{trail.map((t, i) => (
<span key={i} className="flex items-center gap-2">
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{t.href ? (
<Link href={t.href} className="hover:text-neutral-800">{t.label}</Link>
) : (
<span className="font-medium text-neutral-900">{t.label}</span>
)}
</span>
))}
</nav>
);
}
// Server-rendered segmented control: each option is a link that re-renders the
// page with the new value in the query string (used for tier / break-down / top-N).
export function SegLink({
label,
options,
current,
hrefFor,
}: {
label: string;
options: { value: string; label: string }[];
current: string;
hrefFor: (v: string) => string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">{label}</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">
{options.map((o) => (
<Link
key={o.value}
href={hrefFor(o.value)}
className={
"rounded-md px-2.5 py-1 font-medium " +
(o.value === current ? "bg-primary-600 text-white" : "text-neutral-500 hover:text-neutral-800")
}
>
{o.label}
</Link>
))}
</div>
</div>
);
}
// A checkbox rendered as a navigation link — toggles this row's id in the
// `?sel=` custom-comparison selection (keeps the report fully server-rendered).
export function SelectCheckbox({ checked, href, title }: { checked: boolean; href: string; title?: string }) {
return (
<Link
href={href}
title={title ?? "Select to graph"}
scroll={false}
className={
"flex h-4 w-4 shrink-0 items-center justify-center rounded border " +
(checked ? "border-primary-600 bg-primary-600 text-white" : "border-neutral-300 bg-white hover:border-primary-500")
}
>
{checked && <Check className="h-3 w-3" />}
</Link>
);
}
// Sticky banner shown while rows are selected: jump to the custom comparison or clear.
export function CompareBar({ count, compareHref, clearHref }: { count: number; compareHref: string; clearHref: string }) {
return (
<div className="mb-4 flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5">
<span className="text-sm font-medium text-primary-800">{count} selected</span>
<div className="flex items-center gap-2">
<Link href={compareHref} className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700">
Compare selected
</Link>
<Link href={clearHref} className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
Clear
</Link>
</div>
</div>
);
}
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
return (
<div className="mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{title}</h1>
{badge}
</div>
{subtitle && <p className="mt-1 text-sm text-neutral-500">{subtitle}</p>}
</div>
);
}

View file

@ -1,119 +0,0 @@
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { fyLabel, SCOPE_LABELS, type Granularity, type ScopeMode } from "@/lib/reports";
interface Props {
fys: number[];
fy: number;
gran: Granularity;
/** Pass a scope to render the Top/Bottom-N "Show" control (index pages only). */
scope?: ScopeMode;
/** Weekly mode: the selected FY-month index + the 12 month options. */
month?: number;
monthOptions?: { value: number; label: string }[];
exportHref: string;
}
const GRANS: Granularity[] = ["weekly", "monthly", "yearly"];
// Pinned filter toolbar shared by the report pages. Each control writes its value
// into the URL query string (preserving the rest) so the server component
// re-renders the report for the new filters — no client-side data fetching.
export function ReportsToolbar({ fys, fy, gran, scope, month, monthOptions, exportHref }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
function update(patch: Record<string, string | null>) {
const q = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === "") q.delete(k);
else q.set(k, v);
}
const qs = q.toString();
router.push(qs ? `${pathname}?${qs}` : pathname);
}
const yearly = gran === "yearly";
const weekly = gran === "weekly";
return (
<div className="sticky top-0 z-20 -mx-4 mb-6 border-b border-neutral-200 bg-neutral-50/95 px-4 py-3 backdrop-blur md:-mx-6 md:px-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
{GRANS.map((g) => (
<button
key={g}
onClick={() => update({ gran: g === "monthly" ? null : g })}
className={cn(
"rounded-md px-3 py-1 font-medium capitalize transition-colors",
gran === g ? "bg-primary-600 text-white shadow-sm" : "text-neutral-500 hover:text-neutral-800"
)}
>
{g}
</button>
))}
</div>
</div>
{!yearly && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
<select
value={fy}
onChange={(e) => update({ fy: e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{[...fys].reverse().map((y) => (
<option key={y} value={y}>{fyLabel(y)}</option>
))}
</select>
</label>
)}
{weekly && monthOptions && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
<select
value={month}
onChange={(e) => update({ month: e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{monthOptions.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
)}
{scope && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
<select
value={scope}
onChange={(e) => update({ scope: e.target.value === "top5" ? null : e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{(Object.keys(SCOPE_LABELS) as ScopeMode[]).map((s) => (
<option key={s} value={s}>{SCOPE_LABELS[s]}</option>
))}
</select>
</label>
)}
<a
href={exportHref}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
<Download className="h-4 w-4" />
Export
</a>
</div>
</div>
);
}

View file

@ -1,211 +0,0 @@
"use client";
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Search, X } from "lucide-react";
export type VendorOption = { id: string; name: string; vendorId: string | null };
/**
* Filter vendors by a free-text query, matching case-insensitively against the
* vendor name and the formal code (`vendorId`). An empty/whitespace query
* returns the full list unchanged.
*/
export function filterVendors<T extends VendorOption>(vendors: T[], query: string): T[] {
const q = query.trim().toLowerCase();
if (!q) return vendors;
return vendors.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
(v.vendorId ? v.vendorId.toLowerCase().includes(q) : false)
);
}
/** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */
export function vendorLabel(v: VendorOption): string {
return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`;
}
interface Props {
name: string;
vendors: VendorOption[];
/** Initial selected vendor id (uncontrolled — the component owns its state). */
initialValue?: string;
placeholder?: string;
/** Optional callback when the selection changes. */
onChange?: (value: string) => void;
}
export function VendorSelect({
name,
vendors,
initialValue = "",
placeholder = "No vendor selected",
onChange,
}: Props) {
const [value, setValue] = useState(initialValue);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const updatePortalPos = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPortalStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePortalPos();
}, [open, updatePortalPos]);
useEffect(() => {
if (!open) return;
window.addEventListener("scroll", updatePortalPos, true);
window.addEventListener("resize", updatePortalPos);
return () => {
window.removeEventListener("scroll", updatePortalPos, true);
window.removeEventListener("resize", updatePortalPos);
};
}, [open, updatePortalPos]);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("keydown", handleKey);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("keydown", handleKey);
document.removeEventListener("mousedown", handleClick);
};
}, [open]);
useEffect(() => {
if (open) searchRef.current?.focus();
}, [open]);
const selected = vendors.find((v) => v.id === value);
const selectedLabel = selected ? vendorLabel(selected) : "";
const filtered = filterVendors(vendors, query);
const select = useCallback((id: string) => {
setValue(id);
onChange?.(id);
setOpen(false);
setQuery("");
}, [onChange]);
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setValue("");
onChange?.("");
}, [onChange]);
const dropdownPanel = (
<div
style={portalStyle}
className="rounded-lg border border-neutral-200 bg-white shadow-xl"
>
{/* Search input */}
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or code…"
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
/>
{query && (
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Options list */}
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
{/* "No vendor selected" empty option — always available */}
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(""); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === "" ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-500"}`}
>
No vendor selected
</button>
{filtered.length === 0 ? (
<p className="px-3 py-5 text-sm text-center text-neutral-400">No vendors match &ldquo;{query}&rdquo;</p>
) : (
filtered.map((v) => (
<button
key={v.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); select(v.id); }}
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === v.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
>
<span className="flex-1 leading-snug">{v.name}</span>
<span className="font-mono text-xs text-neutral-400 shrink-0">
{v.vendorId ?? "unverified"}
</span>
</button>
))
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative w-full">
<input type="hidden" name={name} value={value} />
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
bg-white text-left transition-colors px-3 py-2.5 text-sm
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20`}
>
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
{selectedLabel || placeholder}
</span>
<span className="flex items-center gap-1 shrink-0">
{value && (
<span role="button" tabIndex={0} onClick={handleClear}
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
className="text-neutral-300 hover:text-neutral-500 transition-colors">
<X className="h-4 w-4" />
</span>
)}
<ChevronDown className={`text-neutral-400 transition-transform h-4 w-4 ${open ? "rotate-180" : ""}`} />
</span>
</button>
{open && mounted && createPortal(dropdownPanel, document.body)}
</div>
);
}

View file

@ -1,47 +0,0 @@
// Shared, dependency-free pagination math used by list pages (e.g. PO History).
// Keeps page-size validation and out-of-range page clamping in one testable place.
export interface PaginationInput {
/** Raw `perPage` value from the query string (may be undefined / invalid). */
perPageParam?: string | number;
/** Raw `page` value from the query string (may be undefined / invalid). */
pageParam?: string | number;
/** Total number of rows matching the current filter. */
total: number;
/** Allowed page sizes; anything else falls back to `defaultPerPage`. */
options: number[];
defaultPerPage: number;
}
export interface Pagination {
perPage: number;
page: number;
totalPages: number;
/** Rows to skip for the current page (Prisma `skip`). */
skip: number;
/** Rows to take for the current page (Prisma `take`). */
take: number;
}
/**
* Resolve a safe page size and (1-based) page number from untrusted query
* params. `perPage` is clamped to the allowed `options`; `page` is clamped to
* `[1, totalPages]` so an out-of-range or non-numeric page never paginates past
* the last page.
*/
export function resolvePagination({
perPageParam,
pageParam,
total,
options,
defaultPerPage,
}: PaginationInput): Pagination {
const perPage = options.includes(Number(perPageParam)) ? Number(perPageParam) : defaultPerPage;
const totalPages = Math.max(1, Math.ceil(total / perPage));
const requested = Number(pageParam);
const page = Math.min(
Math.max(1, Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 1),
totalPages,
);
return { perPage, page, totalPages, skip: (page - 1) * perPage, take: perPage };
}

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

@ -1,105 +0,0 @@
import { db } from "@/lib/db";
/**
* Product catalogue sync registers a PO's line items as reusable `Product`s
* (the `/catalogue/items` catalogue) and keeps last/per-vendor prices fresh:
* - line items with no `productId` are matched to an existing product by name,
* or a brand-new product is created, and the line item is linked back;
* - `lastPrice`/`lastVendorId` and the per-vendor price are upserted.
*
* Called at **approval** (so approved items are immediately reusable in further
* POs) and again at **payment** (to refresh prices on the final figures). The
* function is idempotent re-running matches the same product by name/id.
*/
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
export async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}

View file

@ -1,21 +0,0 @@
// Shared categorical palette for the Reports charts + table swatches.
//
// This is a plain, dependency-free module (NO "use client", no server-only
// imports) so it can be imported by BOTH the server-component report pages and
// the client chart components and resolve to the real array in each. It must NOT
// live in a "use client" module: a plain value imported from a client module
// into a server component becomes a client-reference proxy (not the array), so
// `SERIES_COLORS[i]` would silently be `undefined` and every series would fall
// back to recharts' default colour.
export const SERIES_COLORS = [
"#2563eb",
"#16a34a",
"#9333ea",
"#ea580c",
"#0891b2",
"#dc2626",
"#ca8a04",
"#4f46e5",
"#0d9488",
"#db2777",
];

View file

@ -1,352 +0,0 @@
import { db } from "@/lib/db";
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
/**
* Spend reporting (Reports Purchasing). Aggregates approved purchase-order
* spend across two dimensions:
* Cost centres the PO's vessel (`PurchaseOrder.vesselId`).
* Accounting codes the self-referential `Account` tree (Heading
* Sub-heading Leaf); each PO's `accountId` is a leaf, rolled up to parents.
*
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
* dated by `approvedAt` and valued at the full `totalAmount` the same
* definition the dashboard's spend tiles use. Financial year is the Indian
* AprMar year. The heavy lifting is a single query in `getReportDataset()`;
* everything below is pure functions over that dataset so they're unit-testable.
*/
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
/** Indian FY start year for a date (AprMar): JanMar belong to the prior year. */
export function fyStartYear(d: Date): number {
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
}
/** "FY 202526" for start year 2025. */
export function fyLabel(start: number): string {
return `FY ${start}${String((start + 1) % 100).padStart(2, "0")}`;
}
/** Month index within the FY: Apr=0 … Mar=11. */
export function fyMonthIndex(d: Date): number {
return (d.getMonth() - 3 + 12) % 12;
}
/** Week-of-month bucket: 04 (W1W5) from the day of the month. */
export function weekOfMonth(d: Date): number {
return Math.min(4, Math.floor((d.getDate() - 1) / 7));
}
export const WEEK_LABELS = ["W1", "W2", "W3", "W4", "W5"] as const;
export type Tier = "Heading" | "Sub-heading" | "Leaf";
export interface CostCentre {
id: string;
code: string;
name: string;
}
export interface AccountNode {
id: string;
code: string;
name: string;
parentId: string | null;
tier: Tier;
}
/** One row per (PO, accounting code). Multi-account POs yield several rows. */
export interface SpendRow {
poId: string;
vesselId: string;
accountId: string;
amount: number;
fy: number;
month: number; // 011 within the FY (Apr=0)
week: number; // 04 within the calendar month
}
/**
* Split a PO's spend across the accounting codes its line items carry, so the
* accounting-code report attributes multi-account POs correctly. The PO's
* `totalAmount` is allocated **proportionally** to each line's account share
* (line `accountId`, falling back to the PO-level account), so the per-PO rows
* always sum back to `totalAmount` exactly. With no line items (or zero line
* value) the whole amount lands on the PO-level account.
*/
export function allocatePoSpend(
po: { id: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; week: number },
lines: { accountId: string | null; amount: number }[]
): SpendRow[] {
const base = { poId: po.id, vesselId: po.vesselId, fy: po.fy, month: po.month, week: po.week };
const byAccount = new Map<string, number>();
let lineTotal = 0;
for (const l of lines) {
const key = l.accountId ?? po.accountId;
byAccount.set(key, (byAccount.get(key) ?? 0) + l.amount);
lineTotal += l.amount;
}
if (byAccount.size === 0 || lineTotal <= 0) {
return [{ ...base, accountId: po.accountId, amount: po.amount }];
}
return [...byAccount.entries()].map(([accountId, share]) => ({ ...base, accountId, amount: po.amount * (share / lineTotal) }));
}
export interface ReportDataset {
rows: SpendRow[];
vessels: CostCentre[];
accounts: AccountNode[];
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
}
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
export async function getReportDataset(): Promise<ReportDataset> {
const [pos, vessels, accounts] = await Promise.all([
db.purchaseOrder.findMany({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
select: {
id: true,
vesselId: true,
accountId: true,
totalAmount: true,
approvedAt: true,
lineItems: { select: { accountId: true, totalPrice: true } },
},
}),
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
]);
const childCount = new Map<string, number>();
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
const accountNodes: AccountNode[] = accounts.map((a) => ({
id: a.id,
code: a.code,
name: a.name,
parentId: a.parentId,
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
}));
const rows: SpendRow[] = [];
for (const po of pos) {
if (!po.approvedAt) continue;
const meta = {
id: po.id,
vesselId: po.vesselId,
accountId: po.accountId,
amount: Number(po.totalAmount),
fy: fyStartYear(po.approvedAt),
month: fyMonthIndex(po.approvedAt),
week: weekOfMonth(po.approvedAt),
};
rows.push(...allocatePoSpend(meta, po.lineItems.map((l) => ({ accountId: l.accountId, amount: Number(l.totalPrice) }))));
}
const fySet = new Set(rows.map((r) => r.fy));
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
return { rows, vessels, accounts: accountNodes, fys };
}
// ── Account tree helpers ───────────────────────────────────────────────────
export interface AccountIndex {
byId: Map<string, AccountNode>;
childrenOf: (parentId: string | null) => AccountNode[];
leavesUnder: (id: string) => Set<string>;
isLeaf: (id: string) => boolean;
pathTo: (id: string) => AccountNode[];
}
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
const byId = new Map(accounts.map((a) => [a.id, a]));
const kids = new Map<string | null, AccountNode[]>();
for (const a of accounts) {
const k = a.parentId;
if (!kids.has(k)) kids.set(k, []);
kids.get(k)!.push(a);
}
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
const isLeaf = (id: string) => childrenOf(id).length === 0;
const leafCache = new Map<string, Set<string>>();
function leavesUnder(id: string): Set<string> {
const cached = leafCache.get(id);
if (cached) return cached;
const out = new Set<string>();
const children = childrenOf(id);
if (children.length === 0) out.add(id);
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
leafCache.set(id, out);
return out;
}
function pathTo(id: string): AccountNode[] {
const node = byId.get(id);
if (!node) return [];
return node.parentId ? [...pathTo(node.parentId), node] : [node];
}
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
}
// ── Aggregations ───────────────────────────────────────────────────────────
export interface CostCentreSpend {
id: string;
code: string;
name: string;
total: number; // selected FY
months: number[]; // 12 (AprMar) of the selected FY
poCount: number; // selected FY
fyTotals: number[]; // aligned to ds.fys
}
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
const idx = new Map<string, CostCentreSpend>();
const poSets = new Map<string, Set<string>>(); // distinct POs per vessel in the selected FY
for (const v of ds.vessels) {
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
poSets.set(v.id, new Set());
}
for (const r of ds.rows) {
const row = idx.get(r.vesselId);
if (!row) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) row.fyTotals[fi] += r.amount;
if (r.fy === fy) {
row.months[r.month] += r.amount;
row.total += r.amount;
poSets.get(r.vesselId)!.add(r.poId);
}
}
for (const [id, set] of poSets) idx.get(id)!.poCount = set.size;
return [...idx.values()];
}
/** Weekly buckets (W1W5) of one FY month for a cost centre. */
export function costCentreWeekly(ds: ReportDataset, vesselId: string, fy: number, month: number): number[] {
const weeks = Array(5).fill(0);
for (const r of ds.rows) if (r.vesselId === vesselId && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
return weeks;
}
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
const leaves = idx.leavesUnder(nodeId);
const months = Array(12).fill(0);
const fyTotals = Array(ds.fys.length).fill(0);
const poSet = new Set<string>();
let total = 0;
for (const r of ds.rows) {
if (!leaves.has(r.accountId)) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) fyTotals[fi] += r.amount;
if (r.fy === fy) {
months[r.month] += r.amount;
total += r.amount;
poSet.add(r.poId);
}
}
return { total, months, fyTotals, poCount: poSet.size };
}
/** Weekly buckets (W1W5) of one FY month for an account node (rolls leaves up). */
export function accountNodeWeekly(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number, month: number): number[] {
const leaves = idx.leavesUnder(nodeId);
const weeks = Array(5).fill(0);
for (const r of ds.rows) if (leaves.has(r.accountId) && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
return weeks;
}
export interface NodeSpend {
node: AccountNode;
total: number;
months: number[];
fyTotals: number[];
poCount: number;
}
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
}
export interface Breakdown {
id: string;
label: string;
value: number;
}
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
return ds.accounts
.filter((a) => a.tier === tier)
.map((a) => {
const leaves = idx.leavesUnder(a.id);
let value = 0;
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
return { id: a.id, label: `${a.code} · ${a.name}`, value };
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For an account-node detail: which cost centres drive its spend, this FY. */
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
const leaves = idx.leavesUnder(nodeId);
const byVessel = new Map<string, number>();
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
return ds.vessels
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
return idx
.childrenOf(nodeId)
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
if (scope === "top5") return sortedDesc.slice(0, 5);
if (scope === "top10") return sortedDesc.slice(0, 10);
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
return sortedDesc;
}
export function parseScope(v: string | undefined): ScopeMode {
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
}
export type Granularity = "yearly" | "monthly" | "weekly";
export function parseGranularity(v: string | undefined): Granularity {
return v === "yearly" || v === "weekly" ? v : "monthly";
}
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
const n = v ? Number(v) : NaN;
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
return ds.fys[ds.fys.length - 1];
}
/** Resolve the FY-month index (011) for weekly mode (default: latest month with spend, else 0). */
export function resolveMonth(ds: ReportDataset, fy: number, v: string | undefined): number {
const n = v ? Number(v) : NaN;
if (Number.isFinite(n) && n >= 0 && n <= 11) return n;
let last = 0;
for (const r of ds.rows) if (r.fy === fy && r.month > last) last = r.month;
return last;
}
/** Parse the `?sel=id1,id2` custom-comparison selection into an ordered, de-duped id list. */
export function parseSel(v: string | undefined): string[] {
if (!v) return [];
const seen = new Set<string>();
for (const id of v.split(",")) if (id.trim()) seen.add(id.trim());
return [...seen];
}
/** Toggle an id within a selection list (for the checkbox links). */
export function toggleSel(sel: string[], id: string): string[] {
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
}

View file

@ -59,16 +59,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
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).
* 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).
*/

View file

@ -1,20 +1,11 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
export default auth((req) => {
const isAuthenticated = !!req.auth;
const pathname = req.nextUrl.pathname;
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) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);

View file

@ -1,43 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright config for verifying closed issues against a RUNNING staging instance
* (pm2 `ppms-staging`, port 3200 on pms1), reached over an SSH tunnel:
*
* ssh -N -L 3200:localhost:3200 shad0w@<pms1>
* PLAYWRIGHT_BASE_URL=http://localhost:3200 \
* pnpm exec playwright test --config playwright.staging.config.ts
*
* Unlike playwright.config.ts this does NOT start a local dev server it drives the
* already-deployed staging build. Login uses the seeded `@pelagia.local` test users
* (prisma/seed-test-users.ts), so no production credentials are required.
*
* Staging runs `next dev`, so the first hit on a route compiles on demand and can be
* slow timeouts are deliberately generous and workers default to 1 to keep the
* shared staging DB state predictable across specs.
*/
export default defineConfig({
testDir: "./tests/staging",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-staging" }]],
timeout: 90_000,
expect: { timeout: 20_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3200",
trace: "retain-on-failure",
navigationTimeout: 45_000,
actionTimeout: 20_000,
},
projects: [
{
name: "chromium",
// Use a system browser channel (Google Chrome) so the suite does not depend on
// the bundled chrome-headless-shell download. Override with PW_CHANNEL=msedge
// if Chrome is unavailable. Both ship on a standard Windows install.
use: { ...devices["Desktop Chrome"], channel: process.env.PW_CHANNEL ?? "chrome" },
},
],
});

View file

@ -1,26 +1,52 @@
-- CreateEnum
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS');
-- CreateTable: user-defined T&C categories
CREATE TABLE "TermsCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
-- CreateTable
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
-- CreateTable: clauses belonging to a category
CREATE TABLE "TermsCondition" (
"id" TEXT NOT NULL,
"category" "TermsCategory" NOT NULL,
"categoryId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category");
-- Dynamic T&C snapshot on the PO
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable
-- immediately and existing default wording stays selectable.
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP),
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP),
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP),
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP),
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', CURRENT_TIMESTAMP);
-- Seed: every standard PO T&C line becomes a catalogued clause. "General" holds
-- the previously-fixed boilerplate; the five named slots keep their default
-- wording; "Others" is an empty bucket admins fill. isDefault rows pre-fill new POs.
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
('tcat_dispatch', 'Dispatch Instructions',2, CURRENT_TIMESTAMP),
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP),
('tcc_delivery', 'tcat_delivery', 'Within 4 to 5 days', true, 0, CURRENT_TIMESTAMP),
('tcc_dispatch', 'tcat_dispatch', 'To be transported to site address as above. Freight Supplier''s A/C', true, 0, CURRENT_TIMESTAMP),
('tcc_inspect', 'tcat_inspect', 'NA', true, 0, CURRENT_TIMESTAMP),
('tcc_transit', 'tcat_transit', 'NA', true, 0, CURRENT_TIMESTAMP),
('tcc_payment', 'tcat_payment', 'Within 30 days from delivery.', true, 0, CURRENT_TIMESTAMP);

View file

@ -1,66 +0,0 @@
-- Rework Terms & Conditions (issue #11 follow-up): the fixed TermsCategory ENUM
-- becomes a user-defined TermsCategory TABLE; clauses gain isDefault/sortOrder;
-- PurchaseOrder gains a JSON `terms` snapshot. Existing enum-based clauses are
-- migrated onto the new category rows. Forward migration (the original
-- 20260624140000 migration is already released and stays untouched).
-- Free the "TermsCategory" name (a table and an enum type cannot coexist).
ALTER TYPE "TermsCategory" RENAME TO "TermsCategory_old";
-- New category table.
CREATE TABLE "TermsCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
('tcat_dispatch', 'Dispatch Instructions', 2, CURRENT_TIMESTAMP),
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
-- New clause columns.
ALTER TABLE "TermsCondition" ADD COLUMN "categoryId" TEXT;
ALTER TABLE "TermsCondition" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "TermsCondition" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Migrate existing clauses from the enum onto category rows.
UPDATE "TermsCondition" SET "categoryId" = CASE "category"::text
WHEN 'DELIVERY' THEN 'tcat_delivery'
WHEN 'DISPATCH' THEN 'tcat_dispatch'
WHEN 'INSPECTION' THEN 'tcat_inspect'
WHEN 'TRANSIT_INSURANCE' THEN 'tcat_transit'
WHEN 'PAYMENT_TERMS' THEN 'tcat_payment'
END;
-- The original seed clauses become the PO defaults.
UPDATE "TermsCondition" SET "isDefault" = true
WHERE "id" IN ('tcseed_delivery','tcseed_dispatch','tcseed_inspect','tcseed_transit','tcseed_payment');
-- Drop the old enum column + type now that data is migrated.
DROP INDEX "TermsCondition_category_idx";
ALTER TABLE "TermsCondition" DROP COLUMN "category";
DROP TYPE "TermsCategory_old";
-- Enforce the relation.
ALTER TABLE "TermsCondition" ALTER COLUMN "categoryId" SET NOT NULL;
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the previously-fixed boilerplate lines as default "General" clauses.
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP);
-- Dynamic T&C snapshot on the PO.
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;

View file

@ -22,8 +22,8 @@ export type RankEntry = {
export const RANKS: RankEntry[] = [
// ── Management (portal logins) ──────────────────────────────────────────────
{ code: "PM", name: "Project Manager", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "APM", name: "Assistant Project Manager", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "PM", name: "PM", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "APM", name: "Assistant PM", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "SIC", name: "Site In-charge", parentCode: "APM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
// ── Shore support (no login, no seafarer docs) ──────────────────────────────
@ -34,15 +34,15 @@ export const RANKS: RankEntry[] = [
// ── Operational crew (seafarers) ────────────────────────────────────────────
{ code: "DIC", name: "Dredger In-charge", parentCode: "SIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SDO", name: "Senior Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SDO", name: "Sr. Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLS", name: "Pipeline Supervisor", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLA", name: "Pipeline Assistant", parentCode: "PLS", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "JDO", name: "Junior Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "JDO", name: "Jr. Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ERO", name: "Engine Room Operator", parentCode: "JDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "DH", name: "Deck Hand", parentCode: "ERO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "TR", name: "Trainee", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "MB", name: "Mess Boy", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ELE", name: "Electrician", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SFB", name: "Senior Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SFB", name: "Sr. Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "FW", name: "Fabricator / Welder", parentCode: "SFB", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
];

View file

@ -1,89 +0,0 @@
/**
* Seed deterministic, credential-capable TEST USERS into a database.
*
* Why this exists
* ---------------
* `pelagia_test` (the staging / autofix DB) is a daily mirror of production, so it
* only contains real `@pelagiamarine.com` users most are SSO-only (no password)
* and none have a password we know. That makes it impossible to log into the
* staging instance (port 3200) with the credentials provider to run end-to-end
* feature tests.
*
* This script upserts one **known-password** user per `Role` (using the throwaway
* `@pelagia.local` domain, which never exists in prod, so there is zero collision
* with real accounts). Credentials intentionally mirror
* `tests/e2e/helpers/login.ts` so the same Playwright specs run locally and against
* staging unchanged.
*
* Safety
* ------
* - Idempotent: upsert keyed on the (unique) email; re-running only refreshes the
* password hash / role / isActive.
* - `employeeId` uses a `TEST-*` prefix so it can never clash with a real
* production employee id carried over by the mirror.
* - Only ever creates the `@pelagia.local` users below it touches no prod rows.
*
* Usage
* -----
* DATABASE_URL="postgresql://.../pelagia_test" pnpm tsx prisma/seed-test-users.ts
*
* It is wired into `automation/refresh-test-db.sh` so these accounts are recreated
* automatically after every daily refresh of `pelagia_test`.
*/
import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
/**
* One login per role/flow that the closed-issue feature tests exercise.
* Passwords match tests/e2e/helpers/login.ts (do not change one without the other).
*/
const TEST_USERS: Array<{
employeeId: string;
email: string;
name: string;
password: string;
role: Role;
}> = [
{ employeeId: "TEST-TECH", email: "tech@pelagia.local", name: "Test Technical", password: "tech1234", role: Role.TECHNICAL },
{ employeeId: "TEST-MANNING",email: "manning@pelagia.local", name: "Test Manning", password: "manning1234", role: Role.MANNING },
{ employeeId: "TEST-ACCT", email: "accounts@pelagia.local", name: "Test Accounts", password: "accounts1234", role: Role.ACCOUNTS },
{ employeeId: "TEST-MGR", email: "manager@pelagia.local", name: "Test Manager", password: "manager1234", role: Role.MANAGER },
{ employeeId: "TEST-SUPER", email: "superuser@pelagia.local", name: "Test Superuser", password: "super1234", role: Role.SUPERUSER },
{ employeeId: "TEST-AUDIT", email: "auditor@pelagia.local", name: "Test Auditor", password: "audit1234", role: Role.AUDITOR },
{ employeeId: "TEST-ADMIN", email: "admin@pelagia.local", name: "Test Admin", password: "admin1234", role: Role.ADMIN },
{ employeeId: "TEST-SITE", email: "site@pelagia.local", name: "Test Site Staff", password: "site1234", role: Role.SITE_STAFF},
];
async function main() {
console.log(`Seeding ${TEST_USERS.length} test users...`);
for (const u of TEST_USERS) {
const passwordHash = await bcrypt.hash(u.password, 12);
await prisma.user.upsert({
where: { email: u.email },
// Keep an existing test account in sync (refresh the hash / role / active flag)
// but never overwrite its employeeId once created.
update: { name: u.name, passwordHash, role: u.role, isActive: true },
create: {
employeeId: u.employeeId,
email: u.email,
name: u.name,
passwordHash,
role: u.role,
isActive: true,
},
});
console.log(`${u.email.padEnd(28)} ${u.role}`);
}
console.log("Test users ready.");
}
main()
.catch((e) => {
console.error("seed-test-users failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -4,9 +4,9 @@
* - After adding an item to the cart, the badge count on the cart icon increases
*
* Feature 15 Inventory item & vendor detail pages
* - Clicking an item on /catalogue/items navigates to /catalogue/items/[id]
* - Clicking an item on /inventory/items navigates to /inventory/items/[id]
* - The item detail shows name, price, vendor info
* - /catalogue/vendors/[id] shows vendor details
* - /inventory/vendors/[id] shows vendor details
*
* Created: 2026-05-17
*/
@ -52,7 +52,7 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
await login(page, USERS.TECH);
// Navigate to inventory items
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
const rows = page.locator("tbody tr");
@ -97,15 +97,15 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
});
test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
test("US-15a: clicking an item row navigates to /catalogue/items/[id]", async ({
test("US-15a: clicking an item row navigates to /inventory/items/[id]", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
// Look for a direct link to an item detail page
const itemLink = page.locator("a[href*='/catalogue/items/']").first();
const itemLink = page.locator("a[href*='/inventory/items/']").first();
if (await itemLink.isVisible()) {
await itemLink.click();
await expect(page).toHaveURL(/\/inventory\/items\/.+/);
@ -150,14 +150,14 @@ test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
console.log(`✓ Item detail page loaded: ${page.url()}`);
});
test("US-15b: /catalogue/vendors/[id] shows vendor details for TECHNICAL user", async ({
test("US-15b: /inventory/vendors/[id] shows vendor details for TECHNICAL user", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/vendors");
await page.goto("/inventory/vendors");
await page.waitForLoadState("networkidle");
const vendorLink = page.locator("a[href*='/catalogue/vendors/']").first();
const vendorLink = page.locator("a[href*='/inventory/vendors/']").first();
if (await vendorLink.isVisible()) {
await vendorLink.click();
await expect(page).toHaveURL(/\/inventory\/vendors\/.+/);

View file

@ -1,6 +1,6 @@
/**
* User stories covered: Feature 12 Cheapest & Closest tags
* - TECHNICAL user on /catalogue/items sees Cheapest or Closest tags on item rows
* - TECHNICAL user on /inventory/items sees Cheapest or Closest tags on item rows
* when a site is selected (tags are independent of sort order)
*
* Feature 13 Auto-sort by distance when site selected
@ -17,11 +17,11 @@ import { test, expect } from "@playwright/test";
import { login, USERS } from "../helpers/login";
test.describe("Feature 12 — Cheapest & Closest item tags", () => {
test("US-12a: /catalogue/items page loads for TECHNICAL user", async ({
test("US-12a: /inventory/items page loads for TECHNICAL user", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
// Page should show some items (table rows or empty state)
@ -41,7 +41,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
// Select a site to enable distance computation
@ -54,7 +54,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
}
// Navigate to items with site selected (wait for URL param)
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -106,7 +106,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -116,7 +116,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
return;
}
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -148,7 +148,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -158,7 +158,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
return;
}
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -176,7 +176,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
// Expand a row to reveal sort toggle
@ -196,7 +196,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
}
// Select a site — row stays expanded (preserved React state through soft nav)
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -223,7 +223,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/catalogue/items");
await page.goto("/inventory/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -234,7 +234,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
}
// Select a site
const nav1 = page.waitForURL("**/catalogue/items?siteId=**", {
const nav1 = page.waitForURL("**/inventory/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });

View file

@ -2,7 +2,7 @@
* Integration tests for manager approval server actions.
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
*/
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@ -47,12 +47,6 @@ afterEach(async () => {
await deletePosByTitle(PREFIX);
});
afterAll(async () => {
// Products auto-created by the catalogue-on-approval test.
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
// Helper: create a PO in MGR_REVIEW state
async function createSubmittedPo(title: string): Promise<string> {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
@ -165,40 +159,6 @@ describe("issue #92 — advance payment on approval", () => {
});
});
// ── Product catalogue registered on approval (so items are reusable) ─────────
describe("product catalogue on approval", () => {
it("creates a catalogue product for a free-text line item and links it", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const itemName = `${PREFIX}Starter VPS hosting`;
const form = makePoForm({
title: `${PREFIX}CatApprove`,
vesselId,
accountId,
intent: "submit",
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
});
const { id: poId } = (await createPo(form)) as { id: string };
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
// No catalogue product exists for this name before approval.
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
expect(await approvePo({ poId })).toEqual({ ok: true });
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
expect(product).not.toBeNull();
expect(Number(product!.lastPrice)).toBe(459.95);
// The line item is linked back to the new product, and a per-vendor price is recorded.
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
expect(li.productId).toBe(product!.id);
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
expect(pvp).not.toBeNull();
});
});
// ── M-03: Reject ──────────────────────────────────────────────────────────────
describe("M-03 — reject PO", () => {

View file

@ -17,15 +17,13 @@ vi.mock("@/lib/storage", async (importOriginal) => {
...actual,
uploadBuffer: vi.fn(async () => {}),
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 { db } from "@/lib/db";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { isPdfServiceConfigured } from "@/lib/pdf-service";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_";
@ -100,34 +98,6 @@ describe("prepareVendorEmail", () => {
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 () => {
as(techId, "TECHNICAL");
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Foundation check: the staging instance is reachable and every seeded test user
* can authenticate with the credentials provider. If this fails, none of the
* per-issue specs can run fix the seed (prisma/seed-test-users.ts) or the tunnel.
*/
test.describe("staging smoke", () => {
test("login page renders the staging build", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel(/email address/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
});
for (const [name, creds] of Object.entries(USERS)) {
test(`seeded user ${name} can log in`, async ({ page }) => {
await login(page, creds);
await expect(page).not.toHaveURL(/\/login/);
});
}
});

View file

@ -1,34 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Crewing epics (#75 Requisitions, #76 Candidates, #79 Crew records, #81 Leave &
* Attendance, #83 Office verification, #86 Reference data/admin) feature-flagged
* behind NEXT_PUBLIC_CREWING_ENABLED, which is "true" on staging.
*
* These are smoke checks that each epic's primary surface renders for an authorised
* role (the deep state-machine flows pipeline #77, onboarding #78, PPE #80,
* appraisal #82, sign-off #85 are covered by the existing integration suites noted
* in Docs/TESTING.md). Render-without-redirect is the proof the shipped feature is
* live on staging.
*/
const PAGES: Array<{ issue: string; name: string; path: string; user: keyof typeof USERS; heading: RegExp }> = [
{ issue: "#75", name: "Requisitions", path: "/crewing/requisitions", user: "MANAGER", heading: /requisition/i },
{ issue: "#76", name: "Candidates", path: "/crewing/candidates", user: "MANAGER", heading: /candidate/i },
{ issue: "#79", name: "Crew records", path: "/crewing/crew", user: "MANAGER", heading: /crew/i },
{ issue: "#81", name: "Leave", path: "/crewing/leave", user: "MANAGER", heading: /leave/i },
{ issue: "#81", name: "Attendance", path: "/crewing/attendance", user: "MANAGER", heading: /attendance/i },
{ issue: "#83", name: "Verification", path: "/crewing/verification", user: "MANNING", heading: /verif/i },
{ issue: "#86", name: "Ranks", path: "/admin/ranks", user: "MANAGER", heading: /rank/i },
{ issue: "#86", name: "Crew admin", path: "/admin/crew", user: "MANAGER", heading: /crew/i },
];
for (const p of PAGES) {
test(`${p.issue} ${p.name} surface renders on staging (${p.path})`, async ({ page }) => {
await login(page, USERS[p.user]);
await page.goto(p.path);
await expect(page, `should not redirect away from ${p.path}`).toHaveURL(new RegExp(p.path.replace(/\//g, "\\/")));
await expect(page.getByRole("heading", { name: p.heading }).first()).toBeVisible();
});
}

View file

@ -1,127 +0,0 @@
/**
* Runtime fixture lookups for the staging verification suite.
*
* PO ids in `pelagia_test` change on every daily refresh, so specs must not hard-code
* them. These helpers anchor each spec on a real row that currently exists in the
* staging DB (read-only), keeping the suite stable across refreshes. They use the
* SAME `DATABASE_URL` the Playwright run is given (the SSH tunnel to pelagia_test).
*
* This is test *setup* only every assertion still runs against the live UI.
*/
import { PrismaClient } from "@prisma/client";
let _db: PrismaClient | null = null;
export function db(): PrismaClient {
if (!_db) _db = new PrismaClient();
return _db;
}
export async function closeDb() {
if (_db) await _db.$disconnect();
_db = null;
}
// Mirrors lib/utils.ts POST_APPROVAL_STATUSES — the statuses that count as "approved
// and beyond" for spend/approval trackers (issues #12, #32, #50).
export const POST_APPROVAL_STATUSES = [
"MGR_APPROVED",
"SENT_FOR_PAYMENT",
"PARTIALLY_PAID",
"PAID_DELIVERED",
"PARTIALLY_CLOSED",
"CLOSED",
] as const;
/** First day of the current month in local time (matches the dashboard query). */
export function startOfMonth(now = new Date()): Date {
return new Date(now.getFullYear(), now.getMonth(), 1);
}
/** Mirror of lib/utils.ts formatDate → e.g. "Jun 11, 2026". */
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(date),
);
}
export async function approvedPo() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true, poDate: true },
});
}
/**
* An approved-or-later PO with NO explicit poDate, so its detail "PO Date" must fall
* back to the approval date the exact case issue #5 is about.
*/
export async function approvedPoNoPoDate() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null }, poDate: null },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true },
});
}
export async function closedPo() {
return db().purchaseOrder.findFirst({
where: { status: "CLOSED" },
select: { id: true, poNumber: true },
});
}
/** An approved-or-later PO whose vendor has a contact email (issue #14). */
export async function poWithVendorEmail() {
return db().purchaseOrder.findFirst({
where: {
status: { in: [...POST_APPROVAL_STATUSES] },
vendor: { contacts: { some: { email: { not: null } } } },
},
select: { id: true, poNumber: true, status: true },
});
}
/** A PO that has at least one line item with a non-empty description (issue #8). */
export async function poWithLineItemDescription() {
const li = await db().pOLineItem.findFirst({
where: { description: { not: null }, AND: [{ description: { not: "" } }] },
select: { description: true, po: { select: { id: true, poNumber: true, status: true } } },
});
return li ? { ...li.po, description: li.description as string } : null;
}
/** All CLOSED PO numbers, to assert the manager sees the full closed set (issue #6). */
export async function closedPoNumbers() {
const rows = await db().purchaseOrder.findMany({
where: { status: "CLOSED" },
select: { poNumber: true },
});
return rows.map((r) => r.poNumber);
}
/** A PO that has at least one uploaded document, to exercise attachment grouping (#10). */
export async function poWithDocuments() {
const doc = await db().pODocument.findFirst({
select: { po: { select: { id: true, poNumber: true } } },
});
return doc?.po ?? null;
}
export async function totalPoCount() {
return db().purchaseOrder.count();
}
/** Count of POs approved in the current month, regardless of current status (issues #12/#32). */
export async function approvedThisMonthCount() {
return db().purchaseOrder.count({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { gte: startOfMonth() } },
});
}
/** A vendor that has a non-null vendorId code, for search-by-code specs (#57/#109). */
export async function vendorWithCode() {
return db().vendor.findFirst({
where: { vendorId: { not: null } },
select: { name: true, vendorId: true },
});
}

View file

@ -1,39 +0,0 @@
/**
* Shared helpers for the staging closed-issue verification suite.
*
* Credentials mirror prisma/seed-test-users.ts (seeded into pelagia_test) and the
* dev-seed users in tests/e2e/helpers/login.ts.
*/
import { type Page, expect } from "@playwright/test";
export interface Credentials {
email: string;
password: string;
}
export const USERS = {
TECH: { email: "tech@pelagia.local", password: "tech1234" },
MANNING: { email: "manning@pelagia.local", password: "manning1234" },
ACCOUNTS: { email: "accounts@pelagia.local", password: "accounts1234" },
MANAGER: { email: "manager@pelagia.local", password: "manager1234" },
SUPERUSER: { email: "superuser@pelagia.local", password: "super1234" },
AUDITOR: { email: "auditor@pelagia.local", password: "audit1234" },
ADMIN: { email: "admin@pelagia.local", password: "admin1234" },
SITE: { email: "site@pelagia.local", password: "site1234" },
} satisfies Record<string, Credentials>;
/** Log in via the credentials provider and wait until off the /login page. */
export async function login(page: Page, creds: Credentials): Promise<void> {
await page.goto("/login");
await page.getByLabel(/email address/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
// The staging login page has two buttons: "Sign in with Microsoft" (SSO) and the
// credentials "Sign in" submit. Target the exact credentials submit.
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
}
/** Log out via the header control (best-effort; ignores if already logged out). */
export async function logout(page: Page): Promise<void> {
await page.context().clearCookies();
}

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #4 Submitter can set an optional PO date (back/forward-datable).
* Fix: a `poDate` date input on the PO create/edit forms.
*/
test("#4 PO create form exposes an optional, free-to-set PO Date field", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
const poDate = page.locator('input[name="poDate"]');
await expect(poDate).toBeVisible();
await expect(poDate).toHaveAttribute("type", "date");
await expect(poDate).not.toHaveAttribute("required", "");
// It accepts a back-dated value and a forward-dated value.
await poDate.fill("2024-01-15");
await expect(poDate).toHaveValue("2024-01-15");
await poDate.fill("2030-12-31");
await expect(poDate).toHaveValue("2030-12-31");
});

View file

@ -1,24 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPoNoPoDate, formatDate, closeDb } from "./fixtures";
/**
* Issue #5 Once a PO is approved, the PO date shown is the approval date
* (when the submitter did not set an explicit poDate). Display rule:
* poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt.
*/
test.afterAll(closeDb);
test("#5 approved PO detail shows the approval date as the PO Date", async ({ page }) => {
const po = await approvedPoNoPoDate();
test.skip(!po, "no approved PO without an explicit poDate in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The "PO Date" detail row should render the approval date.
const expected = formatDate(po!.approvedAt!);
const row = page.getByText("PO Date", { exact: true }).locator("xpath=ancestor::*[1]");
await expect(row).toContainText(expected);
});

View file

@ -1,35 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { closedPoNumbers, closeDb } from "./fixtures";
/**
* Issue #6 Closed PO list filters.
* - MANAGER: the "Closed Purchase Orders" view shows ALL closed POs.
* - Submitter: the view shows ONLY CLOSED (no APPROVED leaking in).
* Route: /my-orders.
*/
test.afterAll(closeDb);
test("#6 manager sees ALL closed POs on /my-orders", async ({ page }) => {
const closed = await closedPoNumbers();
test.skip(closed.length === 0, "no closed POs in staging data");
await login(page, USERS.MANAGER);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The manager sees the FULL closed set (not just their own): every closed PO number
// is present, and no APPROVED status leaks into this view.
for (const poNumber of closed) {
await expect(page.getByText(poNumber).first()).toBeVisible();
}
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});
test("#6 submitter's closed view excludes APPROVED POs", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The bug was APPROVED POs showing here; assert none do.
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithLineItemDescription, closeDb } from "./fixtures";
/**
* Issue #8 The exported PO must include the line item's optional description.
* The export route renders the printable PO at /api/po/[id]/export?format=pdf;
* we fetch it with the logged-in session and assert the description text is present.
*/
test.afterAll(closeDb);
test("#8 exported PO contains the line-item description", async ({ page }) => {
const po = await poWithLineItemDescription();
test.skip(!po, "no PO with a line-item description in staging data");
await login(page, USERS.MANAGER);
// page.request shares the authenticated browser cookies.
const res = await page.request.get(`/api/po/${po!.id}/export?format=pdf`);
expect(res.ok()).toBeTruthy();
const body = await res.text();
expect(body).toContain(po!.description);
});

View file

@ -1,25 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithDocuments, closeDb } from "./fixtures";
/**
* Issue #10 PO detail shows ALL attachments, grouped by type
* (Submission / Payment / Delivery). Requires a PO that actually has documents;
* if the staging mirror has none, the spec skips (documented data limitation).
*/
test.afterAll(closeDb);
test("#10 PO detail groups attachments by type", async ({ page }) => {
const po = await poWithDocuments();
test.skip(!po, "no PO with uploaded documents in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The attachments region renders at least one of the typed group headings.
const groups = page.getByText(
/SUBMISSION DOCUMENTS|PAYMENT DOCUMENTS|DELIVERY RECEIPTS|OTHER ATTACHMENTS/i,
);
await expect(groups.first()).toBeVisible();
});

View file

@ -1,18 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #104 /history is paginated with an items-per-page dropdown.
*/
test("#104 history has an items-per-page dropdown that drives a perPage query param", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
const perPage = page.locator("#perPage");
await expect(perPage).toBeVisible();
// Options include the configured page sizes.
await expect(perPage.locator('option[value="50"]')).toBeAttached();
await perPage.selectOption("50");
await expect(page).toHaveURL(/perPage=50/);
});

View file

@ -1,27 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #109 the New PO screen vendor field is a searchable combobox that matches
* by vendor name AND code (mirrors the items search).
*/
test.afterAll(closeDb);
test("#109 new PO vendor field is a searchable combobox (name + code)", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/po/new");
// Open the vendor combobox (trigger shows the "No vendor selected" placeholder).
await page.getByText("No vendor selected").click();
const search = page.getByPlaceholder(/Search by name or code/i);
await expect(search).toBeVisible();
// Searching by the vendor's CODE surfaces the vendor by name — proving code search.
await search.fill(vendor!.vendorId!);
await expect(page.getByText(vendor!.name, { exact: false }).first()).toBeVisible();
});

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #11 Admin-managed Terms & Conditions catalogue feeding a dynamic PO editor.
* - Admin surface at /admin/terms.
* - The PO create form renders a Terms & Conditions editor.
*/
test("#11 admin Terms & Conditions page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/terms");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /terms.*conditions/i }).first()).toBeVisible();
});
test("#11 new PO form includes a Terms & Conditions editor", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
await expect(page.getByText(/terms.*conditions/i).first()).toBeVisible();
// The dynamic editor offers an "Add term" affordance.
await expect(page.getByRole("button", { name: /add term/i })).toBeVisible();
});

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedThisMonthCount, closeDb } from "./fixtures";
/**
* Issue #12 Manager dashboard 'Total approved this month' was stuck at 0 / not
* updating. Fix: the card counts every PO approved this month (any post-approval
* status). We assert the rendered value matches the DB-computed count, proving it
* reflects real data rather than 0.
*/
test.afterAll(closeDb);
test("#12 'Approved This Month' card shows the correct, live count", async ({ page }) => {
const expected = await approvedThisMonthCount();
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("a,div", { has: page.getByText("Approved This Month", { exact: true }) }).first();
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});

View file

@ -1,20 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #13 Accounts dashboard should have a 'Payments completed this month' card
* (analogous to the manager's monthly approval card).
*
* VERIFICATION RESULT: NOT FIXED on staging. The Accounts dashboard currently shows
* only "Ready for Payment" and "Payment Queue Value" there is no
* payments-completed-this-month card. This is marked test.fail() so the suite stays
* green while clearly recording the gap; if the card is later added, this test will
* start passing and flag that the annotation should be removed.
*/
test.fail(true, "Issue #13 not implemented: no 'payments completed this month' card on the Accounts dashboard");
test("#13 Accounts dashboard shows a 'Payments completed this month' card", async ({ page }) => {
await login(page, USERS.ACCOUNTS);
await page.goto("/dashboard");
await expect(page.getByText(/payments? completed this month/i)).toBeVisible();
});

View file

@ -1,21 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithVendorEmail, closeDb } from "./fixtures";
/**
* Issue #14 'Email to vendor' option becomes available once a PO is approved (and
* after payment), when the vendor has a contact email. We assert the button is
* present on such a PO. (The underlying PDF/Outlook pipeline depends on PdfService
* env config; this verifies the user-facing affordance exists.)
*/
test.afterAll(closeDb);
test("#14 approved PO with a vendor email shows the 'Email to vendor' button", async ({ page }) => {
const po = await poWithVendorEmail();
test.skip(!po, "no approved PO with a vendor contact email in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
await expect(page.getByRole("button", { name: /email to vendor/i })).toBeVisible();
});

View file

@ -1,21 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #19 Place of Delivery is a dropdown (admin-managed delivery locations),
* not free text. The PO forms render <select name="placeOfDelivery">, and the admin
* surface lives at /admin/delivery-locations.
*/
test("#19 PO form Place of Delivery is a <select> dropdown", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
const select = page.locator('select[name="placeOfDelivery"]');
await expect(select).toBeVisible();
});
test("#19 admin delivery-locations page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/delivery-locations");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /delivery location/i }).first()).toBeVisible();
});

View file

@ -1,18 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issues #24 / #40 change the sign-out control's tooltip from 'Sign out' to
* 'Log out'. Both were pipeline / button-simulation TEST issues.
*
* VERIFICATION RESULT: NOT FIXED on staging. The header logout control still uses
* title="Sign out". Marked test.fail() to record the gap without failing the suite;
* if the copy is changed to 'Log out' this test will pass and flag the annotation.
*/
test.fail(true, "Issues #24/#40 not implemented: logout tooltip is still 'Sign out'");
test("#24/#40 logout control tooltip reads 'Log out'", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
await expect(page.locator('[title="Log out"]')).toBeVisible();
});

View file

@ -1,27 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { totalPoCount, closeDb } from "./fixtures";
/**
* Issue #41 the dashboard 'Total Purchase Orders' card shows the correct count.
* Issue #26 that stat card is clickable and links to the history page.
* Both are verified on the generic dashboard (AUDITOR role).
*/
test.afterAll(closeDb);
test("#41 'Total Purchase Orders' card shows the correct unfiltered count", async ({ page }) => {
const expected = await totalPoCount();
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Total Purchase Orders" });
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});
test("#26 'Total Purchase Orders' card links to the history page", async ({ page }) => {
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
await page.getByRole("link").filter({ hasText: "Total Purchase Orders" }).click();
await expect(page).toHaveURL(/\/history/);
});

View file

@ -1,21 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #31 PO history allows selecting MULTIPLE statuses (OR-ed). The status
* filter is a checkbox dropdown; applying two statuses yields two `status` query
* params.
*/
test("#31 history status filter supports multiple OR-ed statuses", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
// Open the status dropdown (button label is "All statuses" / "N statuses").
await page.getByRole("button", { name: /statuses/i }).click();
await page.getByRole("checkbox", { name: "Closed" }).check();
await page.getByRole("checkbox", { name: "Approved" }).check();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page).toHaveURL(/status=CLOSED/);
await expect(page).toHaveURL(/status=MGR_APPROVED/);
});

View file

@ -1,21 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #32 the manager 'Approved This Month' card counts POs approved this month
* regardless of their current status, and clicking it opens history pre-filtered by
* approval date (?approvedFrom=YYYY-MM-01).
*/
test("#32 'Approved This Month' card links to history filtered by approval date", async ({ page }) => {
const now = new Date();
const expectedParam = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Approved This Month" });
await expect(card).toBeVisible();
await card.click();
await expect(page).toHaveURL(new RegExp(`/history\\?approvedFrom=${expectedParam}`));
});

View file

@ -1,15 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #44 the PO line-item unit dropdown must offer months and year(s)
* (previously only days/short units).
*/
test("#44 line-item unit dropdown includes month and year options", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
// The line-items editor renders at least one unit <select>; assert the new options.
await expect(page.locator('option[value="month"]').first()).toBeAttached();
await expect(page.locator('option[value="year"]').first()).toBeAttached();
});

View file

@ -1,18 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #50 the manager approved-spend card uses the rupee symbol and compact
* Indian formatting ( with L / Cr / K), not a dollar sign.
*/
test("#50 'Total Approved Spend' card shows ₹ with compact L/Cr/K formatting", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("div").filter({ has: page.getByText("Total Approved Spend", { exact: true }) }).first();
await expect(card).toBeVisible();
// ₹ present, no dollar sign, value matches the compact format (₹2 Cr / ₹49 L / ₹75 K / ₹500).
await expect(card).toContainText("₹");
await expect(card).not.toContainText("$");
await expect(card).toContainText(/₹\s?-?\d[\d.,]*\s?(Cr|L|K)?/);
});

View file

@ -1,38 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPo, closeDb } from "./fixtures";
/**
* Issue #53 Managers can cancel a PO via a confirmation modal that requires a
* reason and typing the word "cancel". This spec verifies the affordance and the
* type-to-confirm guard WITHOUT actually cancelling (non-destructive on staging
* data): it confirms the submit button stays disabled until "cancel" is typed, then
* closes the modal.
*/
test.afterAll(closeDb);
test("#53 Cancel PO modal enforces type-'cancel'-to-confirm", async ({ page }) => {
const po = await approvedPo();
test.skip(!po, "no approved PO in staging data to exercise the cancel control");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await page.getByRole("button", { name: "Cancel PO" }).click();
const submit = page.getByRole("button", { name: "Cancel this PO" });
await expect(submit).toBeVisible();
await expect(submit).toBeDisabled();
// A reason alone is not enough.
await page.getByPlaceholder(/Duplicate order/i).fill("Verification test — not a real cancellation");
await expect(submit).toBeDisabled();
// Typing the confirmation word enables it.
await page.getByPlaceholder("cancel").fill("cancel");
await expect(submit).toBeEnabled();
// Back out without cancelling.
await page.getByRole("button", { name: "Keep PO" }).click();
await expect(submit).toBeHidden();
});

View file

@ -1,22 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #57 /catalogue/vendors (formerly /inventory/vendors) is searchable by
* vendor id/code, and the id is shown next to the name.
*/
test.afterAll(closeDb);
test("#57 vendors are searchable by vendor id, with the id shown next to the name", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a vendorId code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/catalogue/vendors");
await page.getByPlaceholder(/Search by name, ID, GSTIN/i).fill(vendor!.vendorId!);
// The matching vendor row shows both the name and the id badge.
await expect(page.getByText(vendor!.name).first()).toBeVisible();
await expect(page.getByText(vendor!.vendorId!, { exact: false }).first()).toBeVisible();
});

View file

@ -1,31 +0,0 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #96 sidebar section headings (Purchasing / Crewing / Administration) are
* collapsible, collapsed by default, and act as a single-open accordion (opening one
* collapses the others).
*/
test("#96 sidebar sections are collapsible and single-open", async ({ page }) => {
await login(page, USERS.MANAGER);
// /dashboard is not inside any section, so all sections start collapsed.
await page.goto("/dashboard");
const purchasing = page.getByRole("button", { name: "Purchasing" });
const crewing = page.getByRole("button", { name: "Crewing" });
await expect(purchasing).toBeVisible();
await expect(crewing).toBeVisible();
// Collapsed by default.
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
await expect(crewing).toHaveAttribute("aria-expanded", "false");
// Opening Purchasing expands it.
await purchasing.click();
await expect(purchasing).toHaveAttribute("aria-expanded", "true");
// Opening Crewing collapses Purchasing (single-open accordion).
await crewing.click();
await expect(crewing).toHaveAttribute("aria-expanded", "true");
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
});

View file

@ -1,65 +0,0 @@
import { describe, it, expect } from "vitest";
import { resolvePagination } from "@/lib/pagination";
const OPTIONS = [25, 50, 100];
const DEFAULT = 25;
function resolve(perPageParam: string | number | undefined, pageParam: string | number | undefined, total: number) {
return resolvePagination({ perPageParam, pageParam, total, options: OPTIONS, defaultPerPage: DEFAULT });
}
describe("resolvePagination", () => {
it("defaults perPage and page when params are missing", () => {
expect(resolve(undefined, undefined, 200)).toEqual({
perPage: 25,
page: 1,
totalPages: 8,
skip: 0,
take: 25,
});
});
it("accepts allowed page sizes", () => {
expect(resolve("50", "1", 200).perPage).toBe(50);
expect(resolve("100", "1", 200).perPage).toBe(100);
expect(resolve(50, "1", 200).perPage).toBe(50);
});
it("falls back to the default for disallowed or non-numeric page sizes", () => {
expect(resolve("10", "1", 200).perPage).toBe(25);
expect(resolve("999", "1", 200).perPage).toBe(25);
expect(resolve("abc", "1", 200).perPage).toBe(25);
expect(resolve("0", "1", 200).perPage).toBe(25);
});
it("computes skip/take for a middle page", () => {
const p = resolve("25", "3", 200);
expect(p).toMatchObject({ page: 3, skip: 50, take: 25, totalPages: 8 });
});
it("clamps a page beyond the last page to the last page", () => {
expect(resolve("25", "99", 200)).toMatchObject({ page: 8, totalPages: 8, skip: 175 });
});
it("clamps non-positive / non-numeric page to 1", () => {
expect(resolve("25", "0", 200).page).toBe(1);
expect(resolve("25", "-5", 200).page).toBe(1);
expect(resolve("25", "abc", 200).page).toBe(1);
expect(resolve("25", undefined, 200).page).toBe(1);
});
it("floors fractional page numbers", () => {
expect(resolve("25", "2.9", 200).page).toBe(2);
});
it("always yields at least one page, even with zero rows", () => {
expect(resolve("25", "1", 0)).toMatchObject({ page: 1, totalPages: 1, skip: 0 });
});
it("handles a partial final page", () => {
// 23 rows, 25 per page -> single page holding all rows
expect(resolve("25", "1", 23)).toMatchObject({ page: 1, totalPages: 1, take: 25 });
// 60 rows, 25 per page -> 3 pages, last page holds 10
expect(resolve("25", "3", 60)).toMatchObject({ page: 3, totalPages: 3, skip: 50 });
});
});

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,191 +0,0 @@
import { describe, it, expect } from "vitest";
import {
fyStartYear,
fyLabel,
fyMonthIndex,
weekOfMonth,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
accountNodeSpend,
accountNodeWeekly,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
allocatePoSpend,
type ReportDataset,
type AccountNode,
} from "@/lib/reports";
const ACCOUNTS: AccountNode[] = [
{ id: "H", code: "5000", name: "Operating", parentId: null, tier: "Heading" },
{ id: "S", code: "5100", name: "Vessel Running", parentId: "H", tier: "Sub-heading" },
{ id: "L1", code: "5110", name: "Fuel", parentId: "S", tier: "Leaf" },
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
];
// fys ascending: [2024, 2025]. PO p1 is multi-account (rows on L1 + L2).
const DS: ReportDataset = {
vessels: [
{ id: "v1", code: "V1", name: "MV One" },
{ id: "v2", code: "V2", name: "MV Two" },
],
accounts: ACCOUNTS,
fys: [2024, 2025],
rows: [
{ poId: "p1", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 },
{ poId: "p2", vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1, week: 1 },
{ poId: "p1", vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0, week: 0 },
{ poId: "p3", vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5, week: 0 },
{ poId: "p4", vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11, week: 2 },
],
};
describe("financial-year helpers", () => {
it("maps AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
expect(fyStartYear(new Date(2025, 0, 15))).toBe(2024); // Jan → prior FY
expect(fyStartYear(new Date(2025, 2, 31))).toBe(2024); // Mar → prior FY
});
it("labels and indexes months within the FY", () => {
expect(fyLabel(2025)).toBe("FY 202526");
expect(fyMonthIndex(new Date(2025, 3, 1))).toBe(0); // Apr
expect(fyMonthIndex(new Date(2026, 2, 1))).toBe(11); // Mar
});
});
describe("costCentreRows", () => {
it("totals the selected FY by vessel with a 12-month series and PO count", () => {
const rows = costCentreRows(DS, 2025);
const v1 = rows.find((r) => r.id === "v1")!;
expect(v1.total).toBe(180);
expect(v1.months[0]).toBe(130); // 100 + 30
expect(v1.months[1]).toBe(50);
expect(v1.poCount).toBe(2); // distinct POs (p1 is multi-account, + p2) — not row count
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
const v2 = rows.find((r) => r.id === "v2")!;
expect(v2.total).toBe(70);
expect(v2.fyTotals).toEqual([200, 70]);
});
});
describe("accounting-code rollup", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("rolls leaf spend up to the heading", () => {
expect(accountNodeSpend(DS, idx, "H", 2025).total).toBe(250); // 100+50+30+70
expect(accountNodeSpend(DS, idx, "L1", 2025).total).toBe(150);
});
it("lists the children to compare at a drill level", () => {
const top = accountLevelRows(DS, idx, null, 2025); // headings
expect(top.map((r) => r.node.id)).toEqual(["H"]);
const subs = accountLevelRows(DS, idx, "H", 2025);
expect(subs.map((r) => r.node.id)).toEqual(["S"]);
});
it("leaf detection and leaf set", () => {
expect(idx.isLeaf("L1")).toBe(true);
expect(idx.isLeaf("H")).toBe(false);
expect([...idx.leavesUnder("H")].sort()).toEqual(["L1", "L2"]);
});
});
describe("breakdowns", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("top accounting codes for a cost centre (by tier)", () => {
const bd = topAccountsForCostCentre(DS, idx, "v1", 2025, "Leaf");
expect(bd.map((b) => [b.id, b.value])).toEqual([
["L1", 150],
["L2", 30],
]);
});
it("cost centres for an account node", () => {
const bd = costCentresForAccount(DS, idx, "H", 2025);
expect(bd.map((b) => [b.id, b.value])).toEqual([
["v1", 180],
["v2", 70],
]);
});
it("child breakdown of a non-leaf node", () => {
const bd = childBreakdown(DS, idx, "H", 2025);
expect(bd).toEqual([{ id: "S", label: "5100 · Vessel Running", value: 250 }]);
});
});
describe("line-item account allocation (#3)", () => {
const po = { id: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 };
it("splits a PO proportionally across its line-item accounts, summing back to the PO total", () => {
const out = allocatePoSpend(po, [
{ accountId: "L1", amount: 30 },
{ accountId: "L2", amount: 90 },
]);
const byAcc = Object.fromEntries(out.map((r) => [r.accountId, r.amount]));
expect(byAcc["L1"]).toBeCloseTo(25); // 100 * 30/120
expect(byAcc["L2"]).toBeCloseTo(75); // 100 * 90/120
expect(out.reduce((s, r) => s + r.amount, 0)).toBeCloseTo(100);
expect(out.every((r) => r.poId === "po9" && r.vesselId === "v1")).toBe(true);
});
it("falls a line with no account back to the PO-level account", () => {
const out = allocatePoSpend(po, [{ accountId: null, amount: 10 }, { accountId: "L2", amount: 10 }]);
expect(Object.fromEntries(out.map((r) => [r.accountId, r.amount]))).toEqual({ L1: 50, L2: 50 });
});
it("puts the whole amount on the PO account when there are no line items", () => {
expect(allocatePoSpend(po, [])).toEqual([{ poId: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 }]);
});
});
describe("weekly buckets (#1)", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("computes week-of-month from the day", () => {
expect(weekOfMonth(new Date(2025, 3, 1))).toBe(0);
expect(weekOfMonth(new Date(2025, 3, 8))).toBe(1);
expect(weekOfMonth(new Date(2025, 3, 29))).toBe(4);
});
it("buckets a month's spend into weeks for a cost centre and an account node", () => {
expect(costCentreWeekly(DS, "v1", 2025, 0)).toEqual([130, 0, 0, 0, 0]); // both month-0 rows in W1
expect(accountNodeWeekly(DS, idx, "H", 2025, 0)).toEqual([130, 0, 0, 0, 0]);
});
});
describe("custom selection (#2)", () => {
it("parses and de-dupes ?sel=", () => {
expect(parseSel("a,b,a, ,c")).toEqual(["a", "b", "c"]);
expect(parseSel(undefined)).toEqual([]);
});
it("toggles ids in and out", () => {
expect(toggleSel(["a", "b"], "a")).toEqual(["b"]);
expect(toggleSel(["a"], "b")).toEqual(["a", "b"]);
});
});
describe("scope + param parsing", () => {
it("applies Top/Bottom-N to a descending list", () => {
const sorted = [5, 4, 3, 2, 1];
expect(applyScope(sorted, "top5")).toEqual([5, 4, 3, 2, 1]);
expect(applyScope([9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1], "top10")).toHaveLength(10);
expect(applyScope(sorted, "bottom5")).toEqual([1, 2, 3, 4, 5]);
expect(applyScope(sorted, "all")).toEqual(sorted);
});
it("parses scope + resolves FY with sensible defaults", () => {
expect(parseScope("top10")).toBe("top10");
expect(parseScope("garbage")).toBe("top5");
expect(resolveFy(DS, "2024")).toBe(2024);
expect(resolveFy(DS, undefined)).toBe(2025); // latest
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
});
it("parses granularity (incl. weekly) and resolves the weekly month", () => {
expect(parseGranularity("weekly")).toBe("weekly");
expect(parseGranularity("yearly")).toBe("yearly");
expect(parseGranularity(undefined)).toBe("monthly");
expect(resolveMonth(DS, 2025, undefined)).toBe(11); // latest month with spend in FY2025
expect(resolveMonth(DS, 2025, "3")).toBe(3);
expect(resolveMonth(DS, 2025, "99")).toBe(11); // out of range → latest
});
});

View file

@ -100,64 +100,3 @@ describe("Sidebar collapsible sections", () => {
expect(within(adminVendors).queryByText("Vendors")).toBeTruthy();
});
});
describe("Purchase Order links under Purchasing", () => {
it("renders the renamed PO links inside the Purchasing section (not top-level)", () => {
render(<Sidebar userRole="MANAGER" />);
// Collapsed by default → PO links are not in the DOM until Purchasing opens.
expect(screen.queryByRole("link", { name: /New Purchase Order/i })).not.toBeInTheDocument();
fireEvent.click(headerButton("Purchasing"));
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Closed Purchase Orders/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Import Purchase Order/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Purchase Order History/i })).toBeInTheDocument();
});
it("auto-expands Purchasing when a PO route is active", () => {
mockPathname = "/po/new";
render(<Sidebar userRole="MANAGER" />);
expect(headerButton("Purchasing")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
});
it("drops the old PO labels", () => {
render(<Sidebar userRole="MANAGER" />);
fireEvent.click(headerButton("Purchasing"));
// Old labels were "New PO" / "Import PO" / "History".
expect(screen.queryByRole("link", { name: /^New PO$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /^Import PO$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /^History$/i })).not.toBeInTheDocument();
});
});
describe("Reports section (Purchasing subheading)", () => {
it("reveals the report links under a Purchasing subheading for an analytics role", () => {
render(<Sidebar userRole="MANAGER" />);
// Collapsed by default.
expect(screen.queryByRole("link", { name: /Accounting Codes/i })).not.toBeInTheDocument();
fireEvent.click(headerButton("Reports"));
expect(screen.getByRole("link", { name: /Cost Centres/i })).toHaveAttribute("href", "/reports/cost-centres");
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toHaveAttribute("href", "/reports/accounting-codes");
// The "Purchasing" subheading is rendered in addition to the Purchasing section header.
expect(screen.getAllByText("Purchasing").length).toBeGreaterThanOrEqual(2);
});
it("auto-expands Reports when a report route is active", () => {
mockPathname = "/reports/accounting-codes";
render(<Sidebar userRole="MANAGER" />);
expect(headerButton("Reports")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toBeInTheDocument();
});
it("is hidden from roles without view_analytics", () => {
render(<Sidebar userRole="TECHNICAL" />);
expect(screen.queryByRole("button", { name: /^Reports/i })).not.toBeInTheDocument();
});
});

View file

@ -1,94 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
// Each test adds a real <a> to the document so the capture-phase click
// interceptor has something to catch.
const links: HTMLAnchorElement[] = [];
function addLink(href: string) {
const a = document.createElement("a");
a.setAttribute("href", href);
a.textContent = "go";
document.body.appendChild(a);
links.push(a);
return a;
}
beforeEach(() => pushMock.mockClear());
afterEach(() => {
cleanup();
links.splice(0).forEach((a) => a.remove());
});
const noop = () => {};
describe("UnsavedChangesGuard", () => {
it("does not intercept navigation when there are no unsaved changes", () => {
render(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
const link = addLink("/catalogue/vendors");
const notCanceled = fireEvent.click(link);
expect(notCanceled).toBe(true); // default not prevented → navigation proceeds
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
});
it("intercepts an internal link click and opens the prompt when dirty", () => {
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
const link = addLink("/catalogue/vendors");
const notCanceled = fireEvent.click(link);
expect(notCanceled).toBe(false); // navigation blocked
expect(pushMock).not.toHaveBeenCalled();
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save as draft" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Discard changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stay on page" })).toBeInTheDocument();
});
it("does not intercept external links (left to the browser's own prompt)", () => {
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
const link = addLink("https://example.com/elsewhere");
const notCanceled = fireEvent.click(link);
expect(notCanceled).toBe(true);
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
});
it("'Stay on page' closes the prompt without navigating", () => {
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
fireEvent.click(addLink("/dashboard"));
fireEvent.click(screen.getByRole("button", { name: "Stay on page" }));
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
expect(pushMock).not.toHaveBeenCalled();
});
it("'Discard changes' navigates to the intended destination", () => {
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
fireEvent.click(addLink("/catalogue/vendors"));
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
expect(pushMock).toHaveBeenCalledWith("/catalogue/vendors");
});
it("'Save as draft' runs the save handler and closes the prompt", () => {
const onSaveDraft = vi.fn();
render(<UnsavedChangesGuard enabled onSaveDraft={onSaveDraft} saving={false} />);
fireEvent.click(addLink("/dashboard"));
fireEvent.click(screen.getByRole("button", { name: "Save as draft" }));
expect(onSaveDraft).toHaveBeenCalledTimes(1);
expect(pushMock).not.toHaveBeenCalled(); // the save action does its own redirect
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
});
it("arms the browser beforeunload prompt only while dirty", () => {
const { rerender } = render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
const dirtyEvt = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(dirtyEvt);
expect(dirtyEvt.defaultPrevented).toBe(true);
rerender(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
const cleanEvt = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(cleanEvt);
expect(cleanEvt.defaultPrevented).toBe(false);
});
});

View file

@ -1,96 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}));
// The form imports server actions; stub them so the client component renders in jsdom.
vi.mock("@/app/(portal)/admin/vendors/actions", () => ({
createVendor: vi.fn(),
updateVendor: vi.fn(),
toggleVendorActive: vi.fn(),
}));
const GSTIN = "27AAHCP5787B1Z6"; // 15 chars
describe("VendorForm — GSTIN CAPTCHA popup (issue #114)", () => {
beforeEach(() => {
global.fetch = vi.fn(async () =>
new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
headers: { "Content-Type": "application/json" },
}),
) as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
async function openFormAndLookup() {
render(<AddVendorButton simple />);
fireEvent.click(screen.getByText("+ Add Vendor"));
const gstinInput = screen.getByPlaceholderText(/27AAHCP5787B1Z6/);
fireEvent.change(gstinInput, { target: { value: GSTIN } });
fireEvent.click(screen.getByText("Look up"));
// Popup renders the CAPTCHA prompt once the fetch resolves.
await screen.findByText(/Enter the code shown in the image/i);
}
it("opens the CAPTCHA in a popup with a Cancel/Close control, leaving the form footer reachable", async () => {
await openFormAndLookup();
// The CAPTCHA lives in its own popup …
expect(screen.getByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeTruthy();
// Both the form's ✕ and the popup's ✕/Cancel close controls are present.
expect(screen.getAllByLabelText("Close").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Cancel").length).toBeGreaterThanOrEqual(2);
// … and the underlying vendor form's submit button is still rendered (never displaced).
expect(screen.getByText("Create Vendor")).toBeTruthy();
});
it("closes the popup on Cancel without closing the vendor form", async () => {
await openFormAndLookup();
// The popup's Cancel is the first one in the DOM (the CAPTCHA section precedes the footer).
fireEvent.click(screen.getAllByText("Cancel")[0]);
await waitFor(() => {
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
});
// The vendor form itself stays open.
expect(screen.getByText("Create Vendor")).toBeTruthy();
expect(screen.queryByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeNull();
});
it("verifies the CAPTCHA, fills the form fields, and closes the popup on success", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (String(url).includes("/api/gst/captcha")) {
return new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response(
JSON.stringify({
legalName: "Acme Pvt Ltd",
tradeName: "Acme",
address: "1 Dock Rd",
pincode: "400001",
gstin: GSTIN,
status: "Active",
registrationDate: "2020-01-01",
}),
{ headers: { "Content-Type": "application/json" } },
);
});
await openFormAndLookup();
fireEvent.change(screen.getByPlaceholderText("6 digits"), { target: { value: "123456" } });
fireEvent.click(screen.getByText("Verify"));
// Popup closes; success line + populated fields appear on the form.
await waitFor(() => {
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
});
expect((screen.getByDisplayValue("Acme") as HTMLInputElement)).toBeTruthy();
expect(screen.getByText(/Acme Pvt Ltd — Active/)).toBeTruthy();
});
});

View file

@ -1,116 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { filterVendors, vendorLabel, VendorSelect, type VendorOption } from "@/components/ui/vendor-select";
const VENDORS: VendorOption[] = [
{ id: "1", name: "Acme Marine Supplies", vendorId: "V-1001" },
{ id: "2", name: "Bluewater Engineering", vendorId: "V-2002" },
{ id: "3", name: "Coastal Spares", vendorId: null }, // unverified — no code
{ id: "4", name: "Delta Pumps", vendorId: "ACME-99" },
];
// ── Pure filter logic ─────────────────────────────────────────────────────────
describe("filterVendors", () => {
it("returns all vendors for an empty query", () => {
expect(filterVendors(VENDORS, "")).toHaveLength(4);
});
it("returns all vendors for a whitespace-only query", () => {
expect(filterVendors(VENDORS, " ")).toHaveLength(4);
});
it("matches by name (case-insensitive)", () => {
const res = filterVendors(VENDORS, "bluewater");
expect(res.map((v) => v.id)).toEqual(["2"]);
});
it("matches by code (vendorId), case-insensitive", () => {
const res = filterVendors(VENDORS, "v-2002");
expect(res.map((v) => v.id)).toEqual(["2"]);
});
it("matches on a code substring even when the name does not contain it", () => {
// "acme" appears in vendor #1's name AND in vendor #4's code (ACME-99)
const res = filterVendors(VENDORS, "acme");
expect(res.map((v) => v.id).sort()).toEqual(["1", "4"]);
});
it("finds an unverified vendor (null code) by name only", () => {
expect(filterVendors(VENDORS, "coastal").map((v) => v.id)).toEqual(["3"]);
// ...and never crashes on the null code
expect(filterVendors(VENDORS, "9999")).toHaveLength(0);
});
it("returns no matches for an unrelated query", () => {
expect(filterVendors(VENDORS, "zzz")).toHaveLength(0);
});
});
describe("vendorLabel", () => {
it("formats a verified vendor as name (CODE)", () => {
expect(vendorLabel(VENDORS[0])).toBe("Acme Marine Supplies (V-1001)");
});
it("formats an unverified vendor as name (unverified)", () => {
expect(vendorLabel(VENDORS[2])).toBe("Coastal Spares (unverified)");
});
});
// ── Component behaviour ───────────────────────────────────────────────────────
describe("VendorSelect", () => {
it("posts a hidden vendorId input with the empty default", () => {
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} />);
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden).toBeTruthy();
expect(hidden.value).toBe("");
});
it("honours initialValue", () => {
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} initialValue="2" />);
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("2");
// Trigger shows the formatted label
expect(screen.getByText("Bluewater Engineering (V-2002)")).toBeTruthy();
});
it("opens and filters the list by typed query, then selects a vendor", () => {
const onChange = vi.fn();
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} onChange={onChange} />);
fireEvent.click(screen.getByRole("button"));
const search = screen.getByPlaceholderText("Search by name or code…");
fireEvent.change(search, { target: { value: "v-2002" } });
// Only the matching vendor option is in the list
expect(screen.getByText("Bluewater Engineering")).toBeTruthy();
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
fireEvent.mouseDown(screen.getByText("Bluewater Engineering"));
expect(onChange).toHaveBeenCalledWith("2");
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("2");
});
it("keeps 'No vendor selected' selectable to clear the choice", () => {
const onChange = vi.fn();
const { container } = render(
<VendorSelect name="vendorId" vendors={VENDORS} initialValue="1" onChange={onChange} />
);
// The trigger shows the current selection's label; click it to open.
fireEvent.click(screen.getByText("Acme Marine Supplies (V-1001)"));
// The empty option is always present, even before typing
fireEvent.mouseDown(screen.getByText("No vendor selected"));
expect(onChange).toHaveBeenLastCalledWith("");
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
expect(hidden.value).toBe("");
});
it("shows an empty-state message when nothing matches", () => {
render(<VendorSelect name="vendorId" vendors={VENDORS} />);
fireEvent.click(screen.getByRole("button"));
fireEvent.change(screen.getByPlaceholderText("Search by name or code…"), {
target: { value: "zzz" },
});
expect(screen.getByText(/No vendors match/)).toBeTruthy();
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VendorsTable } from "@/app/(portal)/catalogue/vendors/vendors-table";
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),

View file

@ -4,14 +4,6 @@
### 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`.
- **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.
- **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.
@ -37,6 +29,4 @@
### 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).

View file

@ -1,132 +0,0 @@
# Testing
This repo has three test tiers (see `App/CLAUDE.md` → Commands):
| Tier | Tool | Scope | Command |
|---|---|---|---|
| Unit | Vitest (jsdom) | pure functions / components | `pnpm test` |
| Integration | Vitest (node + real DB) | server actions against a Postgres DB | `pnpm test:integration` |
| E2E (local) | Playwright | full UI against a local `pnpm dev` | `pnpm test:e2e` |
This document covers a fourth, purpose-built tier:
## Staging closed-issue verification (`App/tests/staging/`)
A **feature-level** Playwright suite that drives the **running staging instance**
(`pm2 ppms-staging`, port 3200 on pms1 — see [`../automation/README.md`](../automation/README.md) → *Staging*)
to verify that every closed portal issue is actually fixed on the deployed build.
Unlike the local E2E suite it does not start a dev server; it logs in and clicks
through the real staging app, exactly as a user would.
### Why a dedicated tier
Staging runs against `pelagia_test`, a daily mirror of production. That mirror only
contains real `@pelagiamarine.com` users — most are SSO-only and none have a password
we know — so the credentials login can't be used for automated testing. To solve this
without touching production, the refresh seeds **deterministic test users** (one per
role) with known passwords.
### Test users (seeding)
[`App/prisma/seed-test-users.ts`](../App/prisma/seed-test-users.ts) idempotently
upserts one credential-capable login per role on the throwaway `@pelagia.local`
domain (no collision with real accounts):
| Email | Password | Role |
|---|---|---|
| `tech@pelagia.local` | `tech1234` | TECHNICAL |
| `manning@pelagia.local` | `manning1234` | MANNING |
| `accounts@pelagia.local` | `accounts1234` | ACCOUNTS |
| `manager@pelagia.local` | `manager1234` | MANAGER |
| `superuser@pelagia.local` | `super1234` | SUPERUSER |
| `auditor@pelagia.local` | `audit1234` | AUDITOR |
| `admin@pelagia.local` | `admin1234` | ADMIN |
| `site@pelagia.local` | `site1234` | SITE_STAFF |
[`automation/refresh-test-db.sh`](../automation/refresh-test-db.sh) runs this seed
automatically after every daily refresh of `pelagia_test`, so the logins always exist
on staging. To seed manually (e.g. before a one-off run):
```bash
DATABASE_URL="postgresql://…/pelagia_test" pnpm tsx prisma/seed-test-users.ts
```
### Running the suite
From a machine that can reach pms1, open SSH tunnels to the staging app **and** the
DB (the suite reads a few fixture ids straight from `pelagia_test` so it stays stable
across the daily refresh):
```bash
ssh -N -L 3200:localhost:3200 -L 15432:localhost:5432 shad0w@<pms1>
```
Then, from `App/`:
```bash
PLAYWRIGHT_BASE_URL=http://localhost:3200 \
DATABASE_URL="postgresql://pelagia_user:…@localhost:15432/pelagia_test" \
pnpm exec playwright test --config playwright.staging.config.ts
```
- `PLAYWRIGHT_BASE_URL` — the staging app (default `http://localhost:3200`).
- `DATABASE_URL` — the tunnelled staging DB, used only for read-only fixture lookups
(which approved/closed PO to open, expected counts). Every assertion runs against
the live UI.
### What each script verifies (issue → script map)
One spec file per issue; the filename is the mapping. `SKIP` means the staging data
currently has no row to exercise the case (the spec self-skips with a message).
| Issue | Script | Verifies | Result |
|---|---|---|---|
| — | `00-smoke.spec.ts` | staging reachable + all seeded users can log in | PASS |
| #4 | `issue-04-po-date-field.spec.ts` | optional, back/forward-datable PO Date field on the PO form | PASS |
| #5 | `issue-05-approved-date-as-po-date.spec.ts` | approved PO detail shows the approval date as the PO Date | PASS |
| #6 | `issue-06-closed-list-filters.spec.ts` | manager sees all CLOSED POs; submitter's Closed view excludes APPROVED | PASS |
| #8 | `issue-08-export-includes-description.spec.ts` | exported PO includes the line-item optional description | PASS |
| #10 | `issue-10-attachments-grouped.spec.ts` | PO detail groups attachments by type (Submission/Payment/Delivery) | SKIP (no attachment data on staging) |
| #11 | `issue-11-terms-catalogue.spec.ts` | admin T&C catalogue page + dynamic PO terms editor | PASS |
| #12 | `issue-12-approved-this-month-card.spec.ts` | manager 'Approved This Month' card shows the correct live count (was stuck at 0) | PASS |
| #13 | `issue-13-payments-this-month-card.spec.ts` | accounts 'Payments completed this month' card | **KNOWN FAIL — not implemented on staging** |
| #14 | `issue-14-email-to-vendor.spec.ts` | 'Email to vendor' button on an approved PO with a vendor email | PASS |
| #19 | `issue-19-place-of-delivery-dropdown.spec.ts` | Place of Delivery is a dropdown + admin delivery-locations page | PASS |
| #24/#40 | `issue-24-40-logout-tooltip.spec.ts` | logout tooltip reads 'Log out' | **KNOWN FAIL — still 'Sign out' (these were pipeline test issues)** |
| #26/#41 | `issue-26-41-total-po-card.spec.ts` | 'Total Purchase Orders' card count correct (#41) + links to history (#26) | PASS |
| #31 | `issue-31-history-multi-status.spec.ts` | PO history filter accepts multiple OR-ed statuses | PASS |
| #32 | `issue-32-approved-month-clickthrough.spec.ts` | 'Approved This Month' card links to history filtered by approval date | PASS |
| #44 | `issue-44-line-item-units.spec.ts` | line-item unit dropdown includes months and year(s) | PASS |
| #50 | `issue-50-rupee-compact-format.spec.ts` | approved-spend card uses ₹ with compact L/Cr formatting | PASS |
| #53 | `issue-53-cancel-po-modal.spec.ts` | manager Cancel-PO modal with type-'cancel'-to-confirm guard | PASS |
| #57 | `issue-57-vendor-search-catalogue.spec.ts` | /catalogue/vendors searchable by vendor id, id shown next to name | PASS |
| #96 | `issue-96-sidebar-collapsible.spec.ts` | sidebar sections collapsible, collapsed by default, single-open | PASS |
| #104 | `issue-104-history-pagination.spec.ts` | /history items-per-page pagination | PASS |
| #109 | `issue-109-new-po-vendor-search.spec.ts` | new-PO vendor field is a searchable combobox (name + code) | PASS |
| #75/#76/#79/#81/#83/#86 | `crewing-epics.spec.ts` | each crewing epic's primary surface renders for an authorised role | PASS |
### Issues not covered by a staging spec (and why)
| Issue | Reason |
|---|---|
| #1 | "Add CHANGELOG.md" — pipeline bootstrap; verified by the file existing at the repo root, not a runtime feature. |
| #3, #42 | Explicit pipeline / token test issues ("no action needed; close without a fix"). |
| #7 | Inventory-on-approval — the inventory surface is gated by `NEXT_PUBLIC_INVENTORY_ENABLED`, which is **false** on staging, so it is not UI-verifiable there. Covered by the `tests/integration` inventory tests. |
| #17 | GST CAPTCHA extraction — depends on the live external GST portal (GstService); not deterministically testable. |
| #18 | "Prompt to save draft on navigate-away" — a client-side `beforeunload` guard; not reliably driveable in headless Playwright. |
| Crewing deep flows (#77 pipeline, #78 onboarding, #80 PPE, #82 appraisal, #85 sign-off) | State-machine flows covered by the existing integration suites (`tests/integration/applications.test.ts`, `onboarding.test.ts`, `appraisal.test.ts`, `signoff.test.ts`, etc.). The staging suite smoke-checks the epic surfaces render. |
### Findings (verification result)
Running the suite against staging surfaced **two closed issues that are not actually
fixed** on the current build:
- **#13** — the Accounts dashboard has no "Payments completed this month" card (only
"Ready for Payment" and "Payment Queue Value").
- **#24 / #40** — the logout control tooltip still reads "Sign out", not "Log out".
(Both were pipeline / button-simulation test issues, likely closed without a code
change.)
These are encoded as `test.fail()` specs so the suite stays green while the gaps are
recorded; if either is fixed later, its spec flips to passing and flags the
annotation for removal.

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

@ -70,7 +70,6 @@ A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
| Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` |
| Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) |
| PR review-comment watcher | `automation/claude-pr-review-watcher.sh` on pms1 | Addresses `claude-review:` comments on Claude-raised PRs. Own cron entry, own clone (`~/pelagia-pr-review`), own config + lock. See below |
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
@ -94,72 +93,6 @@ activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also sat
The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback;
re-enable its task only if pms1 is unavailable, and disable one before enabling the other.
## PR review-comment watcher
Where the issue watcher turns *issues* into PRs, the **PR review-comment watcher**
([`automation/claude-pr-review-watcher.sh`](claude-pr-review-watcher.sh)) closes the
loop on the other side: it addresses **review comments left on the PRs Claude already
raised**. This is how you iterate on an automated PR without dropping into an
interactive session — leave a comment, Claude pushes a follow-up commit.
**How to use it (as a reviewer):** on any open Claude-raised PR, leave a comment that
starts with the marker **`claude-review:`** — the text after the marker is the
instruction. It works in three places:
- the **PR conversation** (a normal PR comment),
- a **review summary** (the overall body of a submitted review),
- an **inline / on-file comment** (Claude is given the file, line, and diff hunk).
Example inline comment on `App/lib/foo.ts`:
> `claude-review:` this should null-check `order.vendor` before dereferencing it, and add a test for the null case.
**What the watcher does each run (every 10 min via cron):**
1. Lists open PRs Claude raised — head branch starts with `prBranchPrefix` (`claude/`)
or the PR is labelled `claude-pr`.
2. Collects every `claude-review:` comment **from repo collaborators only** (write
access; the repo owner is always included). Comments from anyone else, and the
bot's own comments, are ignored. This is the safety gate — only trusted users can
make Claude push code.
3. Skips comments already handled in a previous run (tracked by a hidden
`<!-- ppms-review-bot handled: … -->` marker the bot stamps on its acknowledgements,
so a 10-minute poll never redoes the same comment).
4. Checks out the **PR's own branch** in `~/pelagia-pr-review`, runs headless Claude
Code with the collected instructions (+ the same `pelagia_test` / port-3100 test
environment the fixer uses), then pushes the new commit(s) to **the same branch**
updating the open PR in place.
5. Acknowledges: posts a reply listing what it addressed (with the handled marker) and
adds a 🚀 reaction to each handled PR-conversation comment.
If Claude judges a comment unclear, out of scope, or too risky to do unattended
(migrations, payments, permissions), it makes no commit for it and the watcher posts a
"produced no change — a human may need to take these" reply. The comments are still
marked handled so the poll doesn't loop on them; re-comment with a clearer
`claude-review:` instruction to retry.
**Deploy on pms1** (mirrors the issue watcher):
```sh
# 1. Place the script + config alongside the issue watcher
cp automation/claude-pr-review-watcher.sh ~/pr-review-watcher/
cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-review-watcher.config.json
# 2. Edit the config: real token (scope write:repository,write:issue), claudeExe = `which claude`
# 3. Add a crontab entry, OFFSET from the issue watcher so the two don't run at the same minute:
# 5,15,25,35,45,55 * * * * PATH=<nvm bin>:$PATH ~/pr-review-watcher/claude-pr-review-watcher.sh >> ~/pr-review-watcher/logs/cron.log 2>&1
```
- **Token scope:** needs `write:repository` (push to the PR branch) **plus**
`write:issue` (post comments + reactions) — one scope more than the issue watcher.
- **Own everything:** separate clone (`~/pelagia-pr-review`), config
(`pr-review-watcher.config.json`), and lock (`.pr-review-watcher.lock`) so it never
races the issue watcher. Logs land in the same `logs/` dir
(`pr-review-<date>.log`, per-PR `claude-pr-<n>-*.log`).
- Same **auth preflight** as the issue watcher — no-ops until Claude Code is signed in
on pms1 (or `ANTHROPIC_API_KEY` is set).
- A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from
`claude-issue-watcher.ps1` only if you need a failover.
## Test database (for autofix verification)
So the fix stage can verify against realistic data without touching production:

View file

@ -1,287 +0,0 @@
#!/usr/bin/env bash
# Claude PR-review-comment watcher -- Linux port (runs on pms1 via cron).
#
# Sibling to claude-issue-watcher.sh. Where that watcher turns *issues* into PRs,
# this one addresses *review comments* left on the PRs Claude already raised.
#
# Per run:
# 1. List open PRs that Claude raised (head branch starts with prBranchPrefix,
# or labelled `claude-pr`).
# 2. On each, collect every comment carrying the marker `claude-review:` --
# from the PR conversation, from review summaries, and from inline (on-file)
# review comments -- but ONLY from repo collaborators (write access).
# 3. Skip comments already handled in a previous run (tracked by a hidden marker
# in the bot's acknowledgement comments).
# 4. Run headless Claude Code on the PR's own branch with those instructions;
# it edits + verifies, the watcher pushes the new commit(s) to the SAME branch
# (updating the PR in place), then acknowledges each comment (reply + reaction).
#
# Config: pr-review-watcher.config.json next to this script (or pass a path as $1).
# See automation/README.md > "PR review-comment watcher".
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG="${1:-$SCRIPT_DIR/pr-review-watcher.config.json}"
[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy pr-review-watcher.config.example.json and fill in the token)"; exit 1; }
cfg() { jq -r "$1" "$CONFIG"; }
FORGEJO_URL=$(cfg .forgejoUrl)
REPO=$(cfg .repo)
TOKEN=$(cfg .token)
WORKDIR=$(cfg .workDir)
BASE_BRANCH=$(cfg .baseBranch)
PR_BRANCH_PREFIX=$(cfg '.prBranchPrefix // "claude/"')
MARKER=$(cfg '.marker // "claude-review:"')
MAX_PRS=$(cfg '.maxPrsPerRun // 1')
MAX_COMMENTS=$(cfg '.maxCommentsPerPr // 20')
CLAUDE=$(cfg .claudeExe)
TURNS=$(cfg '.claudeMaxTurns // 150')
API="$FORGEJO_URL/api/v1"
# Hidden marker the bot stamps on its acknowledgement comments. The "handled:"
# line lists every comment key it has addressed, so subsequent runs skip them.
HANDLED_TAG='ppms-review-bot handled:'
ACK_REACTION='rocket'
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/pr-review-$(date +%F).log"
log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; }
# --- single-instance lock (separate from the issue watcher's) ---
exec 9>"$SCRIPT_DIR/.pr-review-watcher.lock"
if ! flock -n 9; then log "Another PR-review watcher run is active; exiting."; exit 0; fi
# --- preflight: idle until Claude Code is authenticated on this host ---
if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping."
exit 0
fi
# --- Forgejo API helpers (curl + jq) ---
api() { # METHOD PATH [JSON_BODY]
local method=$1 path=$2 body=${3:-}
if [ -n "$body" ]; then
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" --data "$body"
else
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN"
fi
}
# Soft variant: never aborts the run on a single failed call (e.g. reactions
# unsupported on a given comment type). Returns empty + logs instead.
api_soft() { api "$@" 2>/dev/null || { log "api_soft: $1 $2 failed (ignored)"; printf ''; }; }
add_pr_comment() { # NUMBER TEXT
api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null
}
react() { # COMMENT_ID (PR-conversation comments only; best-effort)
api_soft POST "/repos/$REPO/issues/comments/$1/reactions" \
"$(jq -nc --arg c "$ACK_REACTION" '{content:$c}')" >/dev/null
}
# --- prepare the dedicated work clone ---
host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##')
owner=${REPO%%/*}
CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
[ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
if [ ! -d "$WORKDIR/.git" ]; then
log "Cloning $REPO into $WORKDIR"
if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi
git -C "$WORKDIR" config user.name "Claude (review-bot)"
git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com"
fi
# --- authorization set ---
# Collaborators = users with write access. The repo owner is always allowed.
# (The bot may post as the owner's account, so we never filter by author to spot
# the bot's own comments -- its acknowledgements are excluded by the HANDLED_TAG
# marker instead, and human acks lack the claude-review: marker anyway.)
COLLAB=$(api GET "/repos/$REPO/collaborators?limit=100" \
| jq -c --arg owner "$owner" '[.[].login] + [$owner] | unique')
log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")')"
# --- find Claude-raised open PRs (head branch under the prefix, or labelled claude-pr) ---
prs=$(api GET "/repos/$REPO/pulls?state=open&limit=50" \
| jq -c --arg pfx "$PR_BRANCH_PREFIX" \
'[ .[] | select((.head.ref | startswith($pfx)) or (((.labels//[])|map(.name))|index("claude-pr"))) ] | sort_by(.number)')
# Scan ALL matching PRs (not truncated) -- the per-run cap below limits only how
# many PRs Claude actually RUNS on, so comment-less PRs never crowd out newer ones.
n_prs=$(printf '%s' "$prs" | jq 'length')
log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments (will run Claude on up to $MAX_PRS with new comments)"
# Pull the instruction text that follows the marker out of a comment body.
instr_of() { # BODY -> text after the first marker occurrence, trimmed
jq -rn --arg b "$1" --arg m "$MARKER" \
'$b | split($m) | .[1:] | join($m) | gsub("^\\s+|\\s+$";"")'
}
p=0
processed=0
while [ "$p" -lt "$n_prs" ]; do
pr=$(printf '%s' "$prs" | jq -c ".[$p]")
p=$((p+1))
num=$(printf '%s' "$pr" | jq -r .number)
title=$(printf '%s' "$pr" | jq -r .title)
branch=$(printf '%s' "$pr" | jq -r .head.ref)
log "-- PR #$num ($branch): $title"
# ---- gather candidate comments from the three sources ----
conv=$(api GET "/repos/$REPO/issues/$num/comments?limit=100")
reviews=$(api GET "/repos/$REPO/pulls/$num/reviews?limit=100")
# Keys already addressed in a prior run (scanned from the bot's ack comments).
handled=$(printf '%s' "$conv" | jq -c --arg tag "$HANDLED_TAG" \
'[ .[].body // "" | select(contains($tag)) | scan("(?:conv|summary|inline):[0-9]+") ] | unique')
# A candidate must carry the marker, NOT be one of the bot's own ack comments
# (those carry HANDLED_TAG), and come from an authorized (collaborator) user.
sel='select(.body != null) | select(.body | contains($m))
| select(.body | contains($tag) | not)
| select(.user.login as $u | ($collab | index($u)))'
conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
[ .[] | $sel | { key:(\"conv:\"+(.id|tostring)), kind:\"conv\", id:.id, user:.user.login,
loc:\"PR conversation\", body:.body } ]")
summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
[ .[] | select(.body != \"\") | $sel
| { key:(\"summary:\"+(.id|tostring)), kind:\"summary\", id:.id, user:.user.login,
loc:\"review summary\", body:.body } ]")
# Inline (on-file) comments live under each review.
inline_tasks='[]'
for rid in $(printf '%s' "$reviews" | jq -r '.[].id'); do
rc=$(api_soft GET "/repos/$REPO/pulls/$num/reviews/$rid/comments")
[ -z "$rc" ] && continue
t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
[ .[] | $sel
| { key:(\"inline:\"+(.id|tostring)), kind:\"inline\", id:.id, user:.user.login,
loc:(\"inline \"+(.path//\"?\")+\":\"+((.line // .original_line // 0)|tostring)),
hunk:(.diff_hunk // \"\"), body:.body } ]")
inline_tasks=$(jq -nc --argjson a "$inline_tasks" --argjson b "$t" '$a + $b')
done
all=$(jq -nc --argjson a "$conv_tasks" --argjson b "$summary_tasks" --argjson c "$inline_tasks" '$a + $b + $c')
fresh=$(printf '%s' "$all" | jq -c --argjson h "$handled" '[ .[] | select(.key as $k | ($h|index($k)) | not) ]')
fresh=$(printf '%s' "$fresh" | jq -c ".[:$MAX_COMMENTS]")
n=$(printf '%s' "$fresh" | jq 'length')
if [ "$n" -eq 0 ]; then log " no new '$MARKER' comments"; continue; fi
if [ "$processed" -ge "$MAX_PRS" ]; then
log " $n new '$MARKER' comment(s) but per-run cap ($MAX_PRS) reached; deferring PR #$num to next run"
continue
fi
processed=$((processed+1))
log " $n new '$MARKER' comment(s) to address (PR $processed/$MAX_PRS this run)"
# ---- check out the PR branch in the work clone ----
git -C "$WORKDIR" fetch origin -q
if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$branch" -q 2>>"$LOG_FILE"; then
log " checkout of origin/$branch failed; skipping PR #$num"; continue
fi
git -C "$WORKDIR" clean -fdq
# ---- build the prompt ----
keys=$(printf '%s' "$fresh" | jq -r '[.[].key] | join(" ")')
prompt_file=$(mktemp)
{
printf '%s\n' "You are addressing REVIEW COMMENTS on PR #$num of the Pelagia Portal (PPMS), a Next.js 15"
printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first."
printf '%s\n' "You are already checked out on the PR branch '$branch'. Inspect what the PR changed with:"
printf '%s\n' " git -C . log --oneline origin/$BASE_BRANCH..HEAD && git diff origin/$BASE_BRANCH...HEAD"
printf '\n## PR #%s: %s\n\n' "$num" "$title"
printf '%s\n\n' "## Review comments to address (each begins with '$MARKER')"
i=0
while [ "$i" -lt "$n" ]; do
item=$(printf '%s' "$fresh" | jq -c ".[$i]")
i=$((i+1))
u=$(printf '%s' "$item" | jq -r .user)
loc=$(printf '%s' "$item" | jq -r .loc)
body=$(printf '%s' "$item" | jq -r .body)
hunk=$(printf '%s' "$item" | jq -r '.hunk // ""')
instr=$(instr_of "$body")
printf '### Comment %s -- %s (by %s)\n' "$i" "$loc" "$u"
if [ -n "$hunk" ] && [ "$hunk" != "null" ]; then
printf 'Code under review:\n```\n%s\n```\n' "$hunk"
fi
printf 'Instruction: %s\n\n' "$instr"
done
printf '%s\n' "## Test environment available to you"
printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of"
printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and"
printf '%s\n' " storage is local in this dev mode."
printf '%s\n' "- Run integration tests after loading the env:"
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT 3100 ONLY:"
printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)"
printf '%s\n' " Stop ONLY your own server by port ('fuser -k 3100/tcp'); NEVER a broad 'pkill -f next'."
printf '%s\n' ""
printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)"
printf '%s\n' "1. Make the focused changes the review comments ask for -- nothing more."
printf '%s\n' "2. If you change code under App/app|lib|components|hooks, add or update a test (the PR check"
printf '%s\n' " rejects code changes with no test change). Model integration tests on"
printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts."
printf '%s\n' "3. Verify: 'cd App && pnpm type-check' (no new errors); run relevant tests."
printf '%s\n' "4. Update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/, CHANGELOG.md)."
printf '%s\n' "5. Commit ALL changes to the current branch with a conventional message referencing #$num."
printf '%s\n' "6. Do NOT push, do NOT switch branches, do NOT open/close PRs. The supervisor pushes."
printf '%s\n' "If a comment is unclear, out of scope, or too risky to do unattended (migrations, payments,"
printf '%s\n' "permissions), make NO commit for it and explain why in CLAUDE_RESULT.md in the repo root."
} > "$prompt_file"
clog="$LOG_DIR/claude-pr-$num-$(date +%Y%m%d-%H%M%S).log"
log " Running Claude on PR #$num (log: $clog)"
( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \
--max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$?
log " Claude exited with code $rc for PR #$num"
rm -f "$prompt_file"
note=""
if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then
note=$(cat "$WORKDIR/CLAUDE_RESULT.md")
rm -f "$WORKDIR/CLAUDE_RESULT.md"
git -C "$WORKDIR" checkout -- . 2>/dev/null
fi
# ---- build the acknowledgement (lists handled keys + quotes each comment) ----
ack_items=$(printf '%s' "$fresh" | jq -r '.[] | "- **\(.loc)** (by \(.user))"')
commits=$(git -C "$WORKDIR" rev-list "origin/$branch..HEAD" --count 2>/dev/null || echo 0)
if [ "${commits:-0}" -gt 0 ]; then
log " Claude made $commits commit(s); pushing to $branch"
if ! git -C "$WORKDIR" push -u origin "$branch" -q 2>>"$LOG_FILE"; then
log " push failed for PR #$num"
add_pr_comment "$num" "[Claude review-bot] Addressed the review comments locally but the push to \`$branch\` failed. See watcher logs on pms1: \`$clog\`.
<!-- $HANDLED_TAG -->"
continue
fi
body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed):
$ack_items
${note:+
Notes:
$note
}
<!-- $HANDLED_TAG $keys -->"
add_pr_comment "$num" "$body"
# Best-effort reaction on PR-conversation comments (reactions API is keyed
# to issue comments; inline/summary review comments are tracked by the marker).
for cid in $(printf '%s' "$fresh" | jq -r '.[] | select(.kind=="conv") | .id'); do react "$cid"; done
log " PR #$num updated and acknowledged"
else
log " No commits produced for PR #$num"
reason=${note:-"Claude did not produce a change. See watcher logs on pms1: \`$clog\`."}
add_pr_comment "$num" "[Claude review-bot] Reviewed the marked comment(s) but produced no change:
$reason
A human may need to take these:
$ack_items
<!-- $HANDLED_TAG $keys -->"
log " PR #$num: no change, acknowledged (marked handled to avoid re-running)"
fi
done
log "PR-review watcher run complete."

View file

@ -1,13 +0,0 @@
{
"forgejoUrl": "https://git.pelagiamarine.com",
"repo": "shad0w/pelagia-portal",
"token": "<forgejo token with write:repository,write:issue>",
"workDir": "/home/shad0w/pelagia-pr-review",
"baseBranch": "master",
"prBranchPrefix": "claude/",
"marker": "claude-review:",
"maxPrsPerRun": 1,
"maxCommentsPerPr": 20,
"claudeExe": "/home/shad0w/.nvm/versions/node/<ver>/bin/claude",
"claudeMaxTurns": 150
}

View file

@ -74,21 +74,3 @@ if [ -n "$MIG_DIR" ]; then
else
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
fi
# Seed deterministic, credential-capable TEST USERS (one per role) so the staging
# instance can be logged into for end-to-end feature verification. The prod mirror
# only carries real @pelagiamarine.com users (mostly SSO-only, no known password),
# which makes credential login — and therefore the Playwright closed-issue suite
# (App/tests/staging/) — impossible. These @pelagia.local accounts never exist in
# prod, so there is no collision; the seed is idempotent (upsert by email).
# See App/tests/staging/ and Docs/TESTING.md.
if [ -n "$MIG_DIR" ] && [ -f "$MIG_DIR/prisma/seed-test-users.ts" ]; then
log "Seeding test users into $TEST_DB ..."
if ( cd "$MIG_DIR" && DATABASE_URL="$TEST_URL" pnpm tsx prisma/seed-test-users.ts ) >/tmp/seed-test-users.log 2>&1; then
log "Test users seeded."
else
log "WARNING: test-user seed failed; see /tmp/seed-test-users.log"; tail -5 /tmp/seed-test-users.log
fi
else
log "Skipping test-user seed (no checkout with prisma/seed-test-users.ts)."
fi