Compare commits
39 commits
feat/repor
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bf7ea1a9e6 | |||
| 90a831790d | |||
| a5248e5c23 | |||
| efa9d90ddb | |||
|
|
5218eb3717 | ||
| 6c5aebd3fb | |||
| e4f7f6623f | |||
| 7980d68a7a | |||
| 87fbeecf52 | |||
| 02c0806d35 | |||
| 86ccc3e29b | |||
| 6cfcd20c45 | |||
|
|
d0e43135f8 | ||
| fda87e5b13 | |||
| 0814b040e1 | |||
| 6e5b42932b | |||
| fa0e004691 | |||
| 2fa382185f | |||
| 8b56c79f5f | |||
| e1907e6aec | |||
| 70152b0a5e | |||
| 7acd86e3dd | |||
| 262ae5830b | |||
| a9fd927c1f | |||
| 7a4c1c7f62 | |||
| d1af1e6b12 | |||
|
|
4ed27d668b | ||
|
|
3e8f5fb0c7 | ||
| 2fcb207add | |||
| 34143b5e75 | |||
| cf69292be3 | |||
| a72e980558 | |||
| ee8313e10c | |||
| 0e9d06fe71 | |||
| 91349f7564 | |||
| a08ed68569 | |||
| 56497a0d20 | |||
| 7d4ad6a9b8 | |||
|
|
55ae1d46d0 |
82 changed files with 3188 additions and 139 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,6 +32,10 @@ automation/watcher.config.json
|
||||||
automation/logs/
|
automation/logs/
|
||||||
automation/.watcher.lock
|
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
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
1
App/.gitignore
vendored
1
App/.gitignore
vendored
|
|
@ -13,6 +13,7 @@
|
||||||
# Testing
|
# Testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/playwright-report-staging
|
||||||
/test-results
|
/test-results
|
||||||
/blob-report
|
/blob-report
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
|
||||||
|
|
||||||
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
|
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
|
||||||
|
|
||||||
|
### Project Codes (issue #124)
|
||||||
|
|
||||||
|
`ProjectCode` (a unique `code` string + `isActive`) is an admin-managed list that backs the PO **Project Code** dropdown — it replaced an earlier hardcoded `PROJECT_CODES` array. Managed at `/admin/project-codes`, gated by the **`manage_project_codes`** permission (Manager + SuperUser + Admin), mirroring Delivery Locations (table + Add/Edit dialogs + activate/deactivate + delete). The migration seeds the five originally-hardcoded codes so the dropdown stays populated.
|
||||||
|
|
||||||
|
The three PO forms render a shared `<ProjectCodeField options={…}>` — a native `<select name="projectCode">` populated from the **active** codes plus an empty "— none —" option (the field stays **optional**). **`PurchaseOrder.projectCode` stays a nullable free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a code is therefore always safe (no PO references it).
|
||||||
|
|
||||||
### Terms & Conditions catalogue (issue #11)
|
### Terms & Conditions catalogue (issue #11)
|
||||||
|
|
||||||
Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**.
|
Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**.
|
||||||
|
|
@ -113,6 +119,14 @@ 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.
|
- **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).
|
- **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`)
|
### 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 (Apr–Mar) 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.
|
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) 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.
|
||||||
|
|
@ -133,6 +147,8 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once
|
||||||
|
|
||||||
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
|
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
|
||||||
|
|
||||||
|
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey` → `po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
|
||||||
|
|
||||||
### Inventory (feature-flagged)
|
### Inventory (feature-flagged)
|
||||||
|
|
||||||
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
|
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
|
||||||
|
|
@ -156,6 +172,8 @@ Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** sub
|
||||||
|
|
||||||
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
|
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
|
||||||
|
|
||||||
|
**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep.
|
||||||
|
|
||||||
Sites are **not** cost centres (only vessels are).
|
Sites are **not** cost centres (only vessels are).
|
||||||
|
|
||||||
### Crewing (feature-flagged)
|
### Crewing (feature-flagged)
|
||||||
|
|
|
||||||
82
App/app/(portal)/admin/project-codes/actions.ts
Normal file
82
App/app/(portal)/admin/project-codes/actions.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
code: z.string().trim().min(1, "Project code is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Result = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_project_codes")) {
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectCode(formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.projectCode.create({ data: { code: parsed.data.code } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
return { error: "That project code already exists." };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/project-codes");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectCode(id: string, formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.projectCode.update({ where: { id }, data: { code: parsed.data.code } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
return { error: "That project code already exists." };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/project-codes");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleProjectCodeActive(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const code = await db.projectCode.findUnique({ where: { id }, select: { isActive: true } });
|
||||||
|
if (!code) return { error: "Not found" };
|
||||||
|
await db.projectCode.update({ where: { id }, data: { isActive: !code.isActive } });
|
||||||
|
revalidatePath("/admin/project-codes");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectCode(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
// Safe to delete: POs keep their project code as a text snapshot, so no
|
||||||
|
// purchase order references this row.
|
||||||
|
await db.projectCode.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/project-codes");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
28
App/app/(portal)/admin/project-codes/page.tsx
Normal file
28
App/app/(portal)/admin/project-codes/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ProjectCodesTable } from "./project-codes-table";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Project Codes" };
|
||||||
|
|
||||||
|
export default async function ProjectCodesPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_project_codes")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const projectCodes = await db.projectCode.findMany({
|
||||||
|
orderBy: [{ isActive: "desc" }, { code: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectCodesTable
|
||||||
|
projectCodes={projectCodes.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
code: c.code,
|
||||||
|
isActive: c.isActive,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
App/app/(portal)/admin/project-codes/project-code-form.tsx
Normal file
96
App/app/(portal)/admin/project-codes/project-code-form.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { createProjectCode, updateProjectCode } from "./actions";
|
||||||
|
|
||||||
|
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
export type ProjectCodeRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Fields({ projectCode }: { projectCode?: ProjectCodeRow }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Project code *</label>
|
||||||
|
<input name="code" defaultValue={projectCode?.code ?? ""} required className={INPUT} placeholder="e.g. Petronet LNG Cochin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddProjectCodeButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await createProjectCode(new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
|
+ Add Project Code
|
||||||
|
</button>
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Project Code">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditProjectCodeButton({
|
||||||
|
projectCode,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
projectCode: ProjectCodeRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await updateProjectCode(projectCode.id, new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Project Code">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields projectCode={projectCode} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
App/app/(portal)/admin/project-codes/project-codes-table.tsx
Normal file
131
App/app/(portal)/admin/project-codes/project-codes-table.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import {
|
||||||
|
AddProjectCodeButton,
|
||||||
|
EditProjectCodeButton,
|
||||||
|
type ProjectCodeRow,
|
||||||
|
} from "./project-code-form";
|
||||||
|
import { deleteProjectCode, toggleProjectCodeActive } from "./actions";
|
||||||
|
|
||||||
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function ProjectCodeActionsMenu({ projectCode }: { projectCode: ProjectCodeRow }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
|
{projectCode.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditProjectCodeButton projectCode={projectCode} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={projectCode.code}
|
||||||
|
onConfirm={() => deleteProjectCode(projectCode.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toggleOpen}
|
||||||
|
onOpenChange={setToggleOpen}
|
||||||
|
title={projectCode.isActive ? "Deactivate project code?" : "Activate project code?"}
|
||||||
|
description={
|
||||||
|
projectCode.isActive
|
||||||
|
? "It will no longer appear in the Project Code dropdown."
|
||||||
|
: "It will appear in the Project Code dropdown again."
|
||||||
|
}
|
||||||
|
confirmLabel={projectCode.isActive ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => toggleProjectCodeActive(projectCode.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCodesTable({ projectCodes }: { projectCodes: ProjectCodeRow[] }) {
|
||||||
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
|
useTableControls<ProjectCodeRow>({
|
||||||
|
rows: projectCodes,
|
||||||
|
defaultSortKey: "code",
|
||||||
|
searchText: (c) => [c.code, c.isActive ? "active" : "inactive"].join(" "),
|
||||||
|
chipMatch: (c, chip) => {
|
||||||
|
if (chip.toLowerCase() === "active") return c.isActive;
|
||||||
|
if (chip.toLowerCase() === "inactive") return !c.isActive;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
sortValue: (c, key) => {
|
||||||
|
if (key === "isActive") return c.isActive ? "Active" : "Inactive";
|
||||||
|
const val = c[key as keyof ProjectCodeRow];
|
||||||
|
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Project Codes</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Codes that populate the PO “Project Code” dropdown</p>
|
||||||
|
</div>
|
||||||
|
<AddProjectCodeButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableControls
|
||||||
|
search={search}
|
||||||
|
onSearch={setSearch}
|
||||||
|
searchPlaceholder="Search project code…"
|
||||||
|
chips={CHIPS}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onToggleFilter={toggleFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<tr>
|
||||||
|
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>Project Code</SortableTh>
|
||||||
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>Status</SortableTh>
|
||||||
|
<th className="px-4 py-3 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-4 py-8 text-center text-neutral-400">
|
||||||
|
No project codes yet. Add one to populate the Project Code dropdown.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((projectCode) => (
|
||||||
|
<tr key={projectCode.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{projectCode.code}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
projectCode.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||||
|
}`}>
|
||||||
|
{projectCode.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ProjectCodeActionsMenu projectCode={projectCode} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
81
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
|
@ -113,6 +113,44 @@ 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 }) {
|
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
||||||
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||||
const [name, setName] = useState(vendor?.name ?? "");
|
const [name, setName] = useState(vendor?.name ?? "");
|
||||||
|
|
@ -149,13 +187,19 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
||||||
});
|
});
|
||||||
const data: GstResult & { error?: string } = await res.json();
|
const data: GstResult & { error?: string } = await res.json();
|
||||||
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
|
// 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; }
|
||||||
setName(data.tradeName || data.legalName);
|
setName(data.tradeName || data.legalName);
|
||||||
setAddress(data.address);
|
setAddress(data.address);
|
||||||
if (data.pincode) setPincode(data.pincode);
|
if (data.pincode) setPincode(data.pincode);
|
||||||
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
||||||
setCaptchaStep("idle");
|
setCaptchaStep("idle");
|
||||||
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
|
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the CAPTCHA popup without touching the vendor form fields.
|
||||||
|
function closeCaptcha() {
|
||||||
|
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -183,31 +227,46 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{captchaStep === "ready" && captchaB64 && (
|
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
|
||||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
{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>
|
<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"
|
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||||
|
)}
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||||
placeholder="6 digits"
|
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"
|
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
|
autoFocus
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
|
<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">
|
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
|
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
|
||||||
|
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
|
||||||
New image
|
New image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</p>}
|
</CaptchaPopup>
|
||||||
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
{/* 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>}
|
||||||
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/p
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
|
import { ProjectCodeField } from "@/components/po/project-code-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ interface Props {
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
deliveryOptions: string[];
|
||||||
|
projectCodeOptions: string[];
|
||||||
termsCatalogue: CatalogueCategory[];
|
termsCatalogue: CatalogueCategory[];
|
||||||
initialTerms: PoTerm[];
|
initialTerms: PoTerm[];
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +59,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
||||||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) {
|
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -195,7 +197,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Project Code</label>
|
<label className={LABEL}>Project Code</label>
|
||||||
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
|
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Delivery Date Required</label>
|
<label className={LABEL}>Delivery Date Required</label>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -56,6 +56,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
|
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
|
|
@ -63,6 +64,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
const projectCodeOptions = projectCodes.map((c) => c.code);
|
||||||
const termsCatalogue = await getTermsCatalogue();
|
const termsCatalogue = await getTermsCatalogue();
|
||||||
const savedTerms = parsePoTerms(po.terms);
|
const savedTerms = parsePoTerms(po.terms);
|
||||||
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
||||||
|
|
@ -107,6 +109,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
deliveryOptions={deliveryOptions}
|
||||||
|
projectCodeOptions={projectCodeOptions}
|
||||||
termsCatalogue={termsCatalogue}
|
termsCatalogue={termsCatalogue}
|
||||||
initialTerms={initialTerms}
|
initialTerms={initialTerms}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
|
@ -19,11 +21,12 @@ const STATUSES = [
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
vessels: { id: string; name: string }[];
|
||||||
|
accounts: AccountGroup[];
|
||||||
perPageOptions: number[];
|
perPageOptions: number[];
|
||||||
defaultPerPage: number;
|
defaultPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
|
|
@ -33,9 +36,8 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
|
||||||
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
|
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
|
||||||
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const statusRef = useRef<HTMLDivElement>(null);
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -61,9 +63,8 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
|
||||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
|
if (accountId) params.set("accountId", accountId);
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||||
return params;
|
return params;
|
||||||
|
|
@ -78,14 +79,14 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
router.push(qs ? `/history?${qs}` : "/history");
|
router.push(qs ? `/history?${qs}` : "/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
const hasFilters = dateFrom || dateTo || vesselId || accountId || statuses.length > 0;
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
statuses.length === 0
|
statuses.length === 0
|
||||||
|
|
@ -107,16 +108,6 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
|
|
||||||
<input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
|
|
||||||
<input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
||||||
|
|
@ -125,6 +116,16 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
|
||||||
|
<SearchableSelect
|
||||||
|
name="accountId"
|
||||||
|
value={accountId}
|
||||||
|
onChange={setAccountId}
|
||||||
|
groups={accounts}
|
||||||
|
placeholder="All accounting codes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="relative" ref={statusRef}>
|
<div className="relative" ref={statusRef}>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||||
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { HistoryFilters } from "./history-filters";
|
import { HistoryFilters } from "./history-filters";
|
||||||
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||||
import { resolvePagination } from "@/lib/pagination";
|
import { resolvePagination } from "@/lib/pagination";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { POStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "History" };
|
export const metadata: Metadata = { title: "History" };
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ interface Props {
|
||||||
approvedFrom?: string;
|
approvedFrom?: string;
|
||||||
approvedTo?: string;
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
|
accountId?: string;
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
page?: string;
|
page?: string;
|
||||||
perPage?: string;
|
perPage?: string;
|
||||||
|
|
@ -42,33 +44,13 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } =
|
||||||
await searchParams;
|
await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
|
||||||
if (dateFrom || dateTo) {
|
|
||||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
|
||||||
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
|
||||||
if (dateTo) {
|
|
||||||
const end = new Date(dateTo);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
createdAt.lt = end;
|
|
||||||
}
|
|
||||||
where.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
if (approvedFrom || approvedTo) {
|
|
||||||
const approvedAt: { gte?: Date; lt?: Date } = {};
|
|
||||||
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
|
||||||
if (approvedTo) {
|
|
||||||
const end = new Date(approvedTo);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
approvedAt.lt = end;
|
|
||||||
}
|
|
||||||
where.approvedAt = approvedAt;
|
|
||||||
}
|
|
||||||
if (vesselId) where.vesselId = vesselId;
|
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
const where = await buildPoHistoryWhere({
|
||||||
|
dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses,
|
||||||
|
});
|
||||||
|
|
||||||
const total = await db.purchaseOrder.count({ where });
|
const total = await db.purchaseOrder.count({ where });
|
||||||
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
||||||
|
|
@ -79,7 +61,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
defaultPerPage: DEFAULT_PER_PAGE,
|
defaultPerPage: DEFAULT_PER_PAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels, leafAccounts] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, account: true },
|
||||||
|
|
@ -88,8 +70,15 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
take,
|
take,
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.account.findMany({
|
||||||
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
orderBy: { code: "asc" },
|
||||||
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
|
||||||
// Shared filter params for the pagination footer links (everything except `page`).
|
// Shared filter params for the pagination footer links (everything except `page`).
|
||||||
const pageParams = new URLSearchParams();
|
const pageParams = new URLSearchParams();
|
||||||
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
||||||
|
|
@ -97,6 +86,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) pageParams.set("vesselId", vesselId);
|
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||||
|
if (accountId) pageParams.set("accountId", accountId);
|
||||||
for (const s of statuses) pageParams.append("status", s);
|
for (const s of statuses) pageParams.append("status", s);
|
||||||
pageParams.set("perPage", String(perPage));
|
pageParams.set("perPage", String(perPage));
|
||||||
|
|
||||||
|
|
@ -115,6 +105,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
|
if (accountId) exportParams.set("accountId", accountId);
|
||||||
for (const s of statuses) exportParams.append("status", s);
|
for (const s of statuses) exportParams.append("status", s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,7 +131,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
<HistoryFilters vessels={vessels} accounts={accounts} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
@ -150,6 +141,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Accounting Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
|
||||||
|
|
@ -169,6 +161,9 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">
|
||||||
|
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.name}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
|
import { ProjectCodeField } from "@/components/po/project-code-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
|
@ -44,12 +46,13 @@ interface Props {
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
deliveryOptions: string[];
|
||||||
|
projectCodeOptions: string[];
|
||||||
termsCatalogue: CatalogueCategory[];
|
termsCatalogue: CatalogueCategory[];
|
||||||
initialTerms: PoTerm[];
|
initialTerms: PoTerm[];
|
||||||
managerNoteAuthor?: string | null;
|
managerNoteAuthor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
|
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
po.lineItems.map((li) => ({
|
||||||
|
|
@ -69,6 +72,8 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const markDirty = () => setDirty(true);
|
||||||
|
|
||||||
const canSubmit = po.status === "DRAFT";
|
const canSubmit = po.status === "DRAFT";
|
||||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||||
|
|
@ -96,6 +101,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setSubmitting(null);
|
setSubmitting(null);
|
||||||
} else {
|
} else {
|
||||||
|
setDirty(false); // saved — don't warn on the redirect
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +122,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const extPo = po;
|
const extPo = po;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||||
{canResubmit && (
|
{canResubmit && (
|
||||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
<p className="text-sm font-medium text-warning-700">
|
<p className="text-sm font-medium text-warning-700">
|
||||||
|
|
@ -180,7 +186,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -193,7 +199,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
|
@ -246,7 +252,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -263,7 +269,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||||
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
||||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -292,6 +298,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UnsavedChangesGuard
|
||||||
|
enabled={dirty && !submitting}
|
||||||
|
onSaveDraft={() => handleSubmit("save")}
|
||||||
|
saving={submitting === "save"}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes, noteAction] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -42,6 +42,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
|
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
||||||
po.status === "EDITS_REQUESTED"
|
po.status === "EDITS_REQUESTED"
|
||||||
? db.pOAction.findFirst({
|
? db.pOAction.findFirst({
|
||||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||||
|
|
@ -53,6 +54,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
const projectCodeOptions = projectCodes.map((c) => c.code);
|
||||||
const termsCatalogue = await getTermsCatalogue();
|
const termsCatalogue = await getTermsCatalogue();
|
||||||
const savedTerms = parsePoTerms(po.terms);
|
const savedTerms = parsePoTerms(po.terms);
|
||||||
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
||||||
|
|
@ -82,6 +84,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
deliveryOptions={deliveryOptions}
|
||||||
|
projectCodeOptions={projectCodeOptions}
|
||||||
termsCatalogue={termsCatalogue}
|
termsCatalogue={termsCatalogue}
|
||||||
initialTerms={initialTerms}
|
initialTerms={initialTerms}
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
|
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage";
|
||||||
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
|
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
|
||||||
|
|
||||||
type Result = { ok: true; mailto: string; to: string } | { error: string };
|
type Result = { ok: true; mailto: string; to: string } | { error: string };
|
||||||
|
|
@ -47,13 +47,20 @@ export async function prepareVendorEmail(poId: string): Promise<Result> {
|
||||||
return { error: "PDF emailing is not configured on this environment." };
|
return { error: "PDF emailing is not configured on this environment." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render → store → presigned link.
|
// 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.
|
||||||
let link: string;
|
let link: string;
|
||||||
try {
|
try {
|
||||||
const pdf = await renderPoPdf(poId);
|
|
||||||
const slug = po.poNumber.replace(/\//g, "-");
|
const slug = po.poNumber.replace(/\//g, "-");
|
||||||
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
|
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");
|
await uploadBuffer(key, pdf, "application/pdf");
|
||||||
|
}
|
||||||
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
|
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };
|
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
|
import { ProjectCodeField } from "@/components/po/project-code-field";
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
|
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
@ -29,6 +31,7 @@ interface Props {
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
deliveryOptions: string[];
|
||||||
|
projectCodeOptions: string[];
|
||||||
termsCatalogue: CatalogueCategory[];
|
termsCatalogue: CatalogueCategory[];
|
||||||
defaultTerms: PoTerm[];
|
defaultTerms: PoTerm[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
|
|
@ -37,7 +40,7 @@ interface Props {
|
||||||
initialCompanyId?: string;
|
initialCompanyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
|
|
@ -48,6 +51,8 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
const [multiAccount, setMultiAccount] = useState(false);
|
const [multiAccount, setMultiAccount] = useState(false);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const markDirty = () => setDirty(true);
|
||||||
|
|
||||||
async function handleSubmit(intent: "draft" | "submit") {
|
async function handleSubmit(intent: "draft" | "submit") {
|
||||||
setSubmitting(intent);
|
setSubmitting(intent);
|
||||||
|
|
@ -82,11 +87,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setDirty(false); // saved — don't warn on the redirect
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||||
{/* Order Information */}
|
{/* Order Information */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||||
|
|
@ -143,7 +149,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -157,7 +163,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
|
||||||
|
{projectCodeOptions.length === 0 && (
|
||||||
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
|
No project codes configured yet — a Manager can add them under Administration → Project Codes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
|
@ -215,7 +226,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -237,13 +248,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & 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 & Conditions.</p>
|
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration → Terms & Conditions.</p>
|
||||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
|
||||||
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
|
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -268,6 +279,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UnsavedChangesGuard
|
||||||
|
enabled={dirty && !submitting}
|
||||||
|
onSaveDraft={() => handleSubmit("draft")}
|
||||||
|
saving={submitting === "draft"}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -58,10 +58,12 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
|
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
const projectCodeOptions = projectCodes.map((c) => c.code);
|
||||||
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
|
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -78,6 +80,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
deliveryOptions={deliveryOptions}
|
||||||
|
projectCodeOptions={projectCodeOptions}
|
||||||
termsCatalogue={termsCatalogue}
|
termsCatalogue={termsCatalogue}
|
||||||
defaultTerms={defaultTerms}
|
defaultTerms={defaultTerms}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
accountNodeWeekly,
|
accountNodeWeekly,
|
||||||
costCentresForAccount,
|
costCentresForAccount,
|
||||||
childBreakdown,
|
childBreakdown,
|
||||||
|
periodRange,
|
||||||
parseGranularity,
|
parseGranularity,
|
||||||
resolveFy,
|
resolveFy,
|
||||||
resolveMonth,
|
resolveMonth,
|
||||||
|
|
@ -19,7 +20,8 @@ import {
|
||||||
WEEK_LABELS,
|
WEEK_LABELS,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||||
import { TrendChart, BreakdownChart, SERIES_COLORS } from "@/components/reports/charts";
|
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
|
||||||
|
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||||
|
|
||||||
|
|
@ -88,6 +90,10 @@ export default async function AccountingCodeDetail({
|
||||||
return `${base}?${p.toString()}`;
|
return `${base}?${p.toString()}`;
|
||||||
};
|
};
|
||||||
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
|
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
|
||||||
|
// Drill into the POs behind this spend: PO History filtered to this accounting
|
||||||
|
// code (expanded to its leaves) over the period in view (dated by approvedAt).
|
||||||
|
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
||||||
|
const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
||||||
|
|
||||||
const path = idx.pathTo(id);
|
const path = idx.pathTo(id);
|
||||||
const trail = [
|
const trail = [
|
||||||
|
|
@ -110,12 +116,17 @@ export default async function AccountingCodeDetail({
|
||||||
exportHref={exportHref}
|
exportHref={exportHref}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<Link
|
<Link
|
||||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||||
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||||
>
|
>
|
||||||
← Back to Accounting Codes
|
← Back to Accounting Codes
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
||||||
|
View POs · {periodLabel} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ReportTitle
|
<ReportTitle
|
||||||
title={`${node.code} · ${node.name}`}
|
title={`${node.code} · ${node.name}`}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import {
|
||||||
type NodeSpend,
|
type NodeSpend,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||||
import { ComparisonChart, Sparkline, SERIES_COLORS, type Series } from "@/components/reports/charts";
|
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
|
||||||
|
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
costCentreRows,
|
costCentreRows,
|
||||||
costCentreWeekly,
|
costCentreWeekly,
|
||||||
topAccountsForCostCentre,
|
topAccountsForCostCentre,
|
||||||
|
periodRange,
|
||||||
parseGranularity,
|
parseGranularity,
|
||||||
resolveFy,
|
resolveFy,
|
||||||
resolveMonth,
|
resolveMonth,
|
||||||
|
|
@ -19,7 +20,8 @@ import {
|
||||||
type Tier,
|
type Tier,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||||
import { TrendChart, BreakdownChart, SERIES_COLORS } from "@/components/reports/charts";
|
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
|
||||||
|
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||||
|
|
||||||
|
|
@ -79,6 +81,10 @@ export default async function CostCentreDetail({
|
||||||
return `${base}?${p.toString()}`;
|
return `${base}?${p.toString()}`;
|
||||||
};
|
};
|
||||||
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
|
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
|
||||||
|
// Drill into the POs behind this spend: PO History filtered to this cost centre
|
||||||
|
// over the period currently in view (spend is dated by approvedAt).
|
||||||
|
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
||||||
|
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -92,9 +98,14 @@ export default async function CostCentreDetail({
|
||||||
exportHref={exportHref}
|
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">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||||
← Back to Cost Centres
|
← Back to Cost Centres
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
||||||
|
View POs · {periodLabel} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
|
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import {
|
||||||
type CostCentreSpend,
|
type CostCentreSpend,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||||
import { ComparisonChart, Sparkline, SERIES_COLORS, type Series } from "@/components/reports/charts";
|
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
|
||||||
|
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
||||||
|
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import type { POStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
const PO_STATUS_LABELS: Record<string, string> = {
|
const PO_STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||||
|
|
@ -25,36 +25,16 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const sp = request.nextUrl.searchParams;
|
const sp = request.nextUrl.searchParams;
|
||||||
const format = sp.get("format") ?? "csv";
|
const format = sp.get("format") ?? "csv";
|
||||||
const dateFrom = sp.get("dateFrom");
|
|
||||||
const dateTo = sp.get("dateTo");
|
|
||||||
const approvedFrom = sp.get("approvedFrom");
|
|
||||||
const approvedTo = sp.get("approvedTo");
|
|
||||||
const vesselId = sp.get("vesselId");
|
|
||||||
const statuses = sp.getAll("status").filter(Boolean);
|
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where = await buildPoHistoryWhere({
|
||||||
if (dateFrom || dateTo) {
|
dateFrom: sp.get("dateFrom"),
|
||||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
dateTo: sp.get("dateTo"),
|
||||||
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
approvedFrom: sp.get("approvedFrom"),
|
||||||
if (dateTo) {
|
approvedTo: sp.get("approvedTo"),
|
||||||
const end = new Date(dateTo);
|
vesselId: sp.get("vesselId"),
|
||||||
end.setDate(end.getDate() + 1);
|
accountId: sp.get("accountId"),
|
||||||
createdAt.lt = end;
|
statuses: sp.getAll("status"),
|
||||||
}
|
});
|
||||||
where.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
if (approvedFrom || approvedTo) {
|
|
||||||
const approvedAt: { gte?: Date; lt?: Date } = {};
|
|
||||||
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
|
||||||
if (approvedTo) {
|
|
||||||
const end = new Date(approvedTo);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
approvedAt.lt = end;
|
|
||||||
}
|
|
||||||
where.approvedAt = approvedAt;
|
|
||||||
}
|
|
||||||
if (vesselId) where.vesselId = vesselId;
|
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
Gauge,
|
Gauge,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Truck,
|
Truck,
|
||||||
|
FolderKanban,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -123,6 +124,7 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||||
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
||||||
|
{ href: "/admin/project-codes", label: "Project Codes", icon: FolderKanban, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
||||||
{ href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
{ href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
||||||
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||||
...(CREWING_ENABLED
|
...(CREWING_ENABLED
|
||||||
|
|
|
||||||
35
App/components/po/project-code-field.tsx
Normal file
35
App/components/po/project-code-field.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Project Code dropdown (issue #124) — a native <select name="projectCode">
|
||||||
|
* sourced from the admin-managed project codes, plus an empty "— none —" option
|
||||||
|
* (the field stays optional). Plain HTML so it works with the forms' native
|
||||||
|
* FormData submission (no client state needed), matching DeliveryLocationField.
|
||||||
|
*
|
||||||
|
* `options` are the active project-code strings (also the stored value).
|
||||||
|
* `current` is the PO's existing project code; if it isn't one of the active
|
||||||
|
* options (legacy / imported / a since-removed code) it is preserved as a
|
||||||
|
* leading "(current)" option so an edit never silently drops it.
|
||||||
|
*/
|
||||||
|
export function ProjectCodeField({
|
||||||
|
options,
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
current?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const cur = (current ?? "").trim();
|
||||||
|
const currentMissing = cur.length > 0 && !options.includes(cur);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select name="projectCode" defaultValue={cur} className={className}>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{currentMissing && <option value={cur}>{cur} (current)</option>}
|
||||||
|
{options.map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
App/components/po/unsaved-changes-guard.tsx
Normal file
121
App/components/po/unsaved-changes-guard.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,11 @@ import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||||
|
|
||||||
export const SERIES_COLORS = ["#2563eb", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#dc2626", "#ca8a04", "#4f46e5", "#0d9488", "#db2777"];
|
// 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). */
|
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
|
||||||
export function formatINRShort(n: number): string {
|
export function formatINRShort(n: number): string {
|
||||||
|
|
|
||||||
68
App/lib/history-filter.ts
Normal file
68
App/lib/history-filter.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Shared `where` builder for the PO History list (`/history` page) and its
|
||||||
|
* CSV/PDF export route, so the two never drift. Filters: created-date range,
|
||||||
|
* approved-date range, cost centre (vessel), status, and — for report
|
||||||
|
* drill-downs (issue #124 review) — an accounting code.
|
||||||
|
*
|
||||||
|
* The `accountId` filter accepts any account-tree node (Heading / Sub-heading /
|
||||||
|
* Leaf); it expands to the leaf codes underneath via `accountLeafIds` and
|
||||||
|
* matches a PO whose **PO-level account** or **any line item account** is in
|
||||||
|
* that leaf set — the same attribution basis the spend reports use.
|
||||||
|
*/
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { accountLeafIds } from "@/lib/reports";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
type PoWhere = NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"];
|
||||||
|
|
||||||
|
export interface HistoryFilterParams {
|
||||||
|
dateFrom?: string | null;
|
||||||
|
dateTo?: string | null;
|
||||||
|
approvedFrom?: string | null;
|
||||||
|
approvedTo?: string | null;
|
||||||
|
vesselId?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
statuses?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise<PoWhere> {
|
||||||
|
const where: NonNullable<PoWhere> = {};
|
||||||
|
|
||||||
|
if (p.dateFrom || p.dateTo) {
|
||||||
|
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (p.dateFrom) createdAt.gte = new Date(p.dateFrom);
|
||||||
|
if (p.dateTo) {
|
||||||
|
const end = new Date(p.dateTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
createdAt.lt = end;
|
||||||
|
}
|
||||||
|
where.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.approvedFrom || p.approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom);
|
||||||
|
if (p.approvedTo) {
|
||||||
|
const end = new Date(p.approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.vesselId) where.vesselId = p.vesselId;
|
||||||
|
|
||||||
|
const statuses = (p.statuses ?? []).filter(Boolean);
|
||||||
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
|
if (p.accountId) {
|
||||||
|
const accounts = await db.account.findMany({ select: { id: true, parentId: true } });
|
||||||
|
const leaves = accountLeafIds(accounts, p.accountId);
|
||||||
|
where.OR = [
|
||||||
|
{ accountId: { in: leaves } },
|
||||||
|
{ lineItems: { some: { accountId: { in: leaves } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
24
App/lib/pdf-export-auth.ts
Normal file
24
App/lib/pdf-export-auth.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ export type Permission =
|
||||||
| "manage_products"
|
| "manage_products"
|
||||||
| "manage_sites"
|
| "manage_sites"
|
||||||
| "manage_delivery_locations"
|
| "manage_delivery_locations"
|
||||||
|
| "manage_project_codes"
|
||||||
| "manage_terms"
|
| "manage_terms"
|
||||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||||
| "raise_requisition"
|
| "raise_requisition"
|
||||||
|
|
@ -84,6 +85,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
"manage_delivery_locations",
|
"manage_delivery_locations",
|
||||||
|
"manage_project_codes",
|
||||||
"manage_terms",
|
"manage_terms",
|
||||||
"confirm_receipt",
|
"confirm_receipt",
|
||||||
"process_payment"
|
"process_payment"
|
||||||
|
|
@ -105,6 +107,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"export_reports",
|
"export_reports",
|
||||||
"create_vendor",
|
"create_vendor",
|
||||||
"manage_delivery_locations",
|
"manage_delivery_locations",
|
||||||
|
"manage_project_codes",
|
||||||
"manage_terms",
|
"manage_terms",
|
||||||
],
|
],
|
||||||
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
||||||
|
|
@ -120,6 +123,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
"manage_delivery_locations",
|
"manage_delivery_locations",
|
||||||
|
"manage_project_codes",
|
||||||
"manage_terms",
|
"manage_terms",
|
||||||
],
|
],
|
||||||
SITE_STAFF: [],
|
SITE_STAFF: [],
|
||||||
|
|
|
||||||
21
App/lib/report-colors.ts
Normal file
21
App/lib/report-colors.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// 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",
|
||||||
|
];
|
||||||
|
|
@ -350,3 +350,65 @@ export function parseSel(v: string | undefined): string[] {
|
||||||
export function toggleSel(sel: string[], id: string): string[] {
|
export function toggleSel(sel: string[], id: string): string[] {
|
||||||
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
|
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Report → PO drill-down ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaf account ids under `accountId` (the node itself when it is already a
|
||||||
|
* leaf), from the raw `{ id, parentId }` account rows. A report drill-down can
|
||||||
|
* target any tier, but a PO / line item only ever carries a leaf code — so this
|
||||||
|
* translates a drilled node into the concrete leaf set PO History filters by.
|
||||||
|
* Returns `[]` for an unknown id.
|
||||||
|
*/
|
||||||
|
export function accountLeafIds(
|
||||||
|
accounts: { id: string; parentId: string | null }[],
|
||||||
|
accountId: string,
|
||||||
|
): string[] {
|
||||||
|
const ids = new Set(accounts.map((a) => a.id));
|
||||||
|
if (!ids.has(accountId)) return [];
|
||||||
|
const kids = new Map<string, string[]>();
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (a.parentId === null) continue;
|
||||||
|
if (!kids.has(a.parentId)) kids.set(a.parentId, []);
|
||||||
|
kids.get(a.parentId)!.push(a.id);
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (id: string) => {
|
||||||
|
const cs = kids.get(id) ?? [];
|
||||||
|
if (cs.length === 0) out.push(id);
|
||||||
|
else cs.forEach(walk);
|
||||||
|
};
|
||||||
|
walk(accountId);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report
|
||||||
|
* detail view currently shows, so drilling into the underlying POs carries
|
||||||
|
* "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by
|
||||||
|
* `approvedAt`). Mirrors the on-screen period label:
|
||||||
|
* - weekly → the focused FY month
|
||||||
|
* - monthly → the whole selected FY (Apr–Mar)
|
||||||
|
* - yearly → the full span of FYs in the dataset
|
||||||
|
*/
|
||||||
|
export function periodRange(
|
||||||
|
gran: Granularity,
|
||||||
|
fy: number,
|
||||||
|
month: number,
|
||||||
|
fys: number[],
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const iso = (y: number, m: number, d: number) =>
|
||||||
|
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
if (gran === "yearly") {
|
||||||
|
const first = fys[0] ?? fy;
|
||||||
|
const last = fys[fys.length - 1] ?? fy;
|
||||||
|
return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) };
|
||||||
|
}
|
||||||
|
if (gran === "weekly") {
|
||||||
|
const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 0–11
|
||||||
|
const year = fy + (month >= 9 ? 1 : 0); // Jan–Mar roll into the next calendar year
|
||||||
|
const lastDay = new Date(year, cal + 1, 0).getDate();
|
||||||
|
return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) };
|
||||||
|
}
|
||||||
|
return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,16 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
||||||
return `signatures/${userId}.${ext}`;
|
return `signatures/${userId}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so
|
||||||
|
* "Email to vendor" can reuse a previously rendered copy instead of re-rendering
|
||||||
|
* and re-uploading on every send (see `prepareVendorEmail`).
|
||||||
|
*/
|
||||||
|
export function buildPoPdfKey(poId: string, fileName: string): string {
|
||||||
|
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
return `po-pdf/${poId}/${safe}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage key for a company branding asset (logo or stamp/seal).
|
* Storage key for a company branding asset (logo or stamp/seal).
|
||||||
* Deterministic per company+type so a re-upload overwrites the previous file.
|
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||||
|
|
@ -106,6 +116,36 @@ export async function uploadBuffer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight existence/metadata check for a stored object (no body transfer).
|
||||||
|
* Returns `{ lastModified }` when the object exists, or `null` when it doesn't.
|
||||||
|
* Used to reuse a cached PO PDF when it's still current.
|
||||||
|
*/
|
||||||
|
export async function statObject(key: string): Promise<{ lastModified: Date } | null> {
|
||||||
|
try {
|
||||||
|
if (isDev) {
|
||||||
|
const fs = await import("fs/promises");
|
||||||
|
const path = await import("path");
|
||||||
|
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
|
||||||
|
const s = await fs.stat(filePath);
|
||||||
|
return { lastModified: s.mtime };
|
||||||
|
}
|
||||||
|
const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3");
|
||||||
|
const s3 = new S3Client({
|
||||||
|
region: "auto",
|
||||||
|
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }));
|
||||||
|
return { lastModified: r.LastModified ?? new Date(0) };
|
||||||
|
} catch {
|
||||||
|
return null; // missing object (404/NotFound) or any access error → treat as absent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a stored file as a Buffer (server-side).
|
* Fetch a stored file as a Buffer (server-side).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
|
||||||
|
|
||||||
export default auth((req) => {
|
export default auth((req) => {
|
||||||
const isAuthenticated = !!req.auth;
|
const isAuthenticated = !!req.auth;
|
||||||
const pathname = req.nextUrl.pathname;
|
const pathname = req.nextUrl.pathname;
|
||||||
const isLoginPage = pathname === "/login";
|
const isLoginPage = pathname === "/login";
|
||||||
|
|
||||||
|
// PdfService fetches the PO export page unauthenticated, using a `svc` token
|
||||||
|
// that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that
|
||||||
|
// one route through so the service token isn't bounced to /login by the gate
|
||||||
|
// below. Everything else stays auth-protected.
|
||||||
|
if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated && !isLoginPage) {
|
if (!isAuthenticated && !isLoginPage) {
|
||||||
const loginUrl = new URL("/login", req.url);
|
const loginUrl = new URL("/login", req.url);
|
||||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
|
|
||||||
43
App/playwright.staging.config.ts
Normal file
43
App/playwright.staging.config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProjectCode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProjectCode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProjectCode_code_key" ON "ProjectCode"("code");
|
||||||
|
|
||||||
|
-- Seed the previously-hardcoded project codes so the dropdown stays populated
|
||||||
|
-- after this feature replaces the static list (issue #124).
|
||||||
|
INSERT INTO "ProjectCode" ("id", "code", "updatedAt") VALUES
|
||||||
|
('pcseed_petronet', 'Petronet LNG Cochin', CURRENT_TIMESTAMP),
|
||||||
|
('pcseed_comacoe_trombay', 'COMACOE Trombay', CURRENT_TIMESTAMP),
|
||||||
|
('pcseed_haldia_reach', 'Haldia Reach', CURRENT_TIMESTAMP),
|
||||||
|
('pcseed_haldia_mmt', 'Haldia MMT', CURRENT_TIMESTAMP),
|
||||||
|
('pcseed_comacoe_mandvi', 'COMACOE Mandvi', CURRENT_TIMESTAMP);
|
||||||
|
|
@ -410,6 +410,19 @@ model DeliveryLocation {
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin-managed project codes (issue #124). An admin-curated list of project
|
||||||
|
// codes that backs the PO "Project Code" dropdown (previously a hardcoded list).
|
||||||
|
// The PO stores the chosen text snapshot in PurchaseOrder.projectCode (nullable,
|
||||||
|
// point-in-time document), so editing/removing a code never rewrites historical
|
||||||
|
// POs. Managed by manage_project_codes.
|
||||||
|
model ProjectCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
// Admin-managed Terms & Conditions catalogue (issue #11). Categories are
|
// Admin-managed Terms & Conditions catalogue (issue #11). Categories are
|
||||||
// user-defined data (not a fixed set) — admins add new ones — and every PO T&C
|
// user-defined data (not a fixed set) — admins add new ones — and every PO T&C
|
||||||
// line is a catalogued clause, including the standard "fixed" lines (seeded under
|
// line is a catalogued clause, including the standard "fixed" lines (seeded under
|
||||||
|
|
|
||||||
89
App/prisma/seed-test-users.ts
Normal file
89
App/prisma/seed-test-users.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
|
@ -17,13 +17,15 @@ vi.mock("@/lib/storage", async (importOriginal) => {
|
||||||
...actual,
|
...actual,
|
||||||
uploadBuffer: vi.fn(async () => {}),
|
uploadBuffer: vi.fn(async () => {}),
|
||||||
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
|
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
|
||||||
|
statObject: vi.fn(async () => null), // default: no cached object → render
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
|
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
|
||||||
import { isPdfServiceConfigured } from "@/lib/pdf-service";
|
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
|
||||||
|
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
|
||||||
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
|
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
|
||||||
|
|
||||||
const PREFIX = "INTTEST_EMAILVENDOR_";
|
const PREFIX = "INTTEST_EMAILVENDOR_";
|
||||||
|
|
@ -98,6 +100,34 @@ describe("prepareVendorEmail", () => {
|
||||||
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
|
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
|
||||||
|
as(techId, "TECHNICAL");
|
||||||
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
||||||
|
|
||||||
|
// 1st send: no cached object → render + upload once.
|
||||||
|
vi.mocked(statObject).mockResolvedValueOnce(null);
|
||||||
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||||
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
|
||||||
|
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
|
||||||
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||||
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
|
||||||
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
|
||||||
|
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-renders when the PO changed since the cached copy", async () => {
|
||||||
|
as(techId, "TECHNICAL");
|
||||||
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
||||||
|
// Cached object older than the PO's updatedAt → stale → re-render.
|
||||||
|
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
|
||||||
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||||
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
|
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
|
||||||
as(techId, "TECHNICAL");
|
as(techId, "TECHNICAL");
|
||||||
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);
|
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);
|
||||||
|
|
|
||||||
128
App/tests/integration/history-account-filter.test.ts
Normal file
128
App/tests/integration/history-account-filter.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* Integration test for the PO History accounting-code filter (PR #126 review).
|
||||||
|
*
|
||||||
|
* Report drill-downs link from a cost-centre / accounting-code detail page into
|
||||||
|
* `/history` with the code (and period) applied as filters. `buildPoHistoryWhere`
|
||||||
|
* powers both the History page and its export route. The accounting-code filter
|
||||||
|
* accepts any tree node and must match a PO whose **PO-level account** OR **any
|
||||||
|
* line item account** falls under that node's leaves — the same attribution
|
||||||
|
* basis the spend reports use.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
||||||
|
import { deletePosByTitle } from "./helpers";
|
||||||
|
|
||||||
|
const PREFIX = "INTTEST_HIST_ACCT_";
|
||||||
|
const CODE = `INTTEST_${Date.now()}_`;
|
||||||
|
|
||||||
|
let submitterId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
let topId: string; // heading
|
||||||
|
let leafAId: string;
|
||||||
|
let leafBId: string;
|
||||||
|
let leafXId: string; // unrelated leaf
|
||||||
|
|
||||||
|
const approvedAt = new Date("2025-06-15T12:00:00Z"); // FY 2025–26
|
||||||
|
|
||||||
|
async function makePo(opts: {
|
||||||
|
title: string;
|
||||||
|
accountId: string;
|
||||||
|
lineAccountId?: string | null;
|
||||||
|
}) {
|
||||||
|
return db.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
poNumber: `${PREFIX}${opts.title}`,
|
||||||
|
title: `${PREFIX}${opts.title}`,
|
||||||
|
status: "CLOSED",
|
||||||
|
totalAmount: 1000,
|
||||||
|
approvedAt,
|
||||||
|
submitterId,
|
||||||
|
vesselId,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
lineItems: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
name: "Item",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "pc",
|
||||||
|
unitPrice: 1000,
|
||||||
|
totalPrice: 1000,
|
||||||
|
accountId: opts.lineAccountId ?? null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [user, vessel] = await Promise.all([
|
||||||
|
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
|
||||||
|
db.vessel.findFirstOrThrow(),
|
||||||
|
]);
|
||||||
|
submitterId = user.id;
|
||||||
|
vesselId = vessel.id;
|
||||||
|
|
||||||
|
// Brand-new account subtree (T → S → A, B) + an unrelated leaf X, so no
|
||||||
|
// pre-existing prod PO references them and counts isolate to our fixtures.
|
||||||
|
const top = await db.account.create({ data: { code: `${CODE}T`, name: "IntTest Top" } });
|
||||||
|
const sub = await db.account.create({ data: { code: `${CODE}S`, name: "IntTest Sub", parentId: top.id } });
|
||||||
|
const a = await db.account.create({ data: { code: `${CODE}A`, name: "IntTest Leaf A", parentId: sub.id } });
|
||||||
|
const b = await db.account.create({ data: { code: `${CODE}B`, name: "IntTest Leaf B", parentId: sub.id } });
|
||||||
|
const x = await db.account.create({ data: { code: `${CODE}X`, name: "IntTest Leaf X" } });
|
||||||
|
topId = top.id;
|
||||||
|
leafAId = a.id;
|
||||||
|
leafBId = b.id;
|
||||||
|
leafXId = x.id;
|
||||||
|
|
||||||
|
await makePo({ title: "po1_levelA", accountId: leafAId }); // PO-level A
|
||||||
|
await makePo({ title: "po2_levelX_lineB", accountId: leafXId, lineAccountId: leafBId }); // line item B
|
||||||
|
await makePo({ title: "po3_levelX", accountId: leafXId }); // X only
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deletePosByTitle(PREFIX);
|
||||||
|
await db.account.deleteMany({ where: { code: { startsWith: CODE } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function countMine(accountId: string) {
|
||||||
|
const where = await buildPoHistoryWhere({ accountId });
|
||||||
|
return db.purchaseOrder.count({ where: { ...where, title: { startsWith: PREFIX } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PO History accounting-code filter", () => {
|
||||||
|
it("a heading expands to its leaves and matches PO-level or line-item accounts", async () => {
|
||||||
|
// T → {A, B}: po1 (PO-level A) and po2 (line item B); not po3 (X only).
|
||||||
|
expect(await countMine(topId)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a leaf matches only POs carrying that exact code (PO-level)", async () => {
|
||||||
|
expect(await countMine(leafAId)).toBe(1); // po1
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a PO via a line-item account even when the PO-level account differs", async () => {
|
||||||
|
expect(await countMine(leafBId)).toBe(1); // po2 (PO-level X, line item B)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the unrelated leaf matches its own POs", async () => {
|
||||||
|
expect(await countMine(leafXId)).toBe(2); // po2 + po3 (both PO-level X)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines with the approved-date window", async () => {
|
||||||
|
const inWindow = await buildPoHistoryWhere({
|
||||||
|
accountId: topId,
|
||||||
|
approvedFrom: "2025-04-01",
|
||||||
|
approvedTo: "2026-03-31",
|
||||||
|
});
|
||||||
|
expect(await db.purchaseOrder.count({ where: { ...inWindow, title: { startsWith: PREFIX } } })).toBe(2);
|
||||||
|
|
||||||
|
const outOfWindow = await buildPoHistoryWhere({
|
||||||
|
accountId: topId,
|
||||||
|
approvedFrom: "2024-04-01",
|
||||||
|
approvedTo: "2025-03-31",
|
||||||
|
});
|
||||||
|
expect(await db.purchaseOrder.count({ where: { ...outOfWindow, title: { startsWith: PREFIX } } })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
App/tests/integration/project-codes.test.ts
Normal file
81
App/tests/integration/project-codes.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the Project Codes admin CRUD (issue #124).
|
||||||
|
* Covers create/update/toggle/delete + the manage_project_codes guard.
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, afterAll } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
createProjectCode,
|
||||||
|
updateProjectCode,
|
||||||
|
toggleProjectCodeActive,
|
||||||
|
deleteProjectCode,
|
||||||
|
} from "@/app/(portal)/admin/project-codes/actions";
|
||||||
|
import { makeSession, fd } from "./helpers";
|
||||||
|
|
||||||
|
const mockedAuth = vi.mocked(auth);
|
||||||
|
const PREFIX = "INTTEST_PROJCODE_";
|
||||||
|
|
||||||
|
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.projectCode.deleteMany({ where: { code: { startsWith: PREFIX } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createProjectCode", () => {
|
||||||
|
it("persists an active project code", async () => {
|
||||||
|
asManager();
|
||||||
|
const result = await createProjectCode(fd({ code: `${PREFIX}Alpha` }));
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const code = await db.projectCode.findFirstOrThrow({ where: { code: `${PREFIX}Alpha` } });
|
||||||
|
expect(code.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a non-empty code", async () => {
|
||||||
|
asManager();
|
||||||
|
expect("error" in (await createProjectCode(fd({ code: " " })))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate code", async () => {
|
||||||
|
asManager();
|
||||||
|
await createProjectCode(fd({ code: `${PREFIX}Dup` }));
|
||||||
|
const result = await createProjectCode(fd({ code: `${PREFIX}Dup` }));
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses callers without manage_project_codes", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
expect(await createProjectCode(fd({ code: `${PREFIX}X` }))).toEqual({ error: "Forbidden" });
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
|
||||||
|
expect(await createProjectCode(fd({ code: `${PREFIX}X` }))).toEqual({ error: "Forbidden" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProjectCode / toggle / delete", () => {
|
||||||
|
it("edits, toggles active, then deletes a project code", async () => {
|
||||||
|
asManager();
|
||||||
|
await createProjectCode(fd({ code: `${PREFIX}Old` }));
|
||||||
|
const code = await db.projectCode.findFirstOrThrow({ where: { code: `${PREFIX}Old` } });
|
||||||
|
|
||||||
|
expect(await updateProjectCode(code.id, fd({ code: `${PREFIX}New` }))).toEqual({ ok: true });
|
||||||
|
expect((await db.projectCode.findUniqueOrThrow({ where: { id: code.id } })).code).toBe(`${PREFIX}New`);
|
||||||
|
|
||||||
|
expect(await toggleProjectCodeActive(code.id)).toEqual({ ok: true });
|
||||||
|
expect((await db.projectCode.findUniqueOrThrow({ where: { id: code.id } })).isActive).toBe(false);
|
||||||
|
|
||||||
|
expect(await deleteProjectCode(code.id)).toEqual({ ok: true });
|
||||||
|
expect(await db.projectCode.findUnique({ where: { id: code.id } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards update/toggle/delete behind the permission", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
expect(await updateProjectCode("x", fd({ code: "y" }))).toEqual({ error: "Forbidden" });
|
||||||
|
expect(await toggleProjectCodeActive("x")).toEqual({ error: "Forbidden" });
|
||||||
|
expect(await deleteProjectCode("x")).toEqual({ error: "Forbidden" });
|
||||||
|
});
|
||||||
|
});
|
||||||
22
App/tests/staging/00-smoke.spec.ts
Normal file
22
App/tests/staging/00-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
34
App/tests/staging/crewing-epics.spec.ts
Normal file
34
App/tests/staging/crewing-epics.spec.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
127
App/tests/staging/fixtures.ts
Normal file
127
App/tests/staging/fixtures.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
39
App/tests/staging/helpers.ts
Normal file
39
App/tests/staging/helpers.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
22
App/tests/staging/issue-04-po-date-field.spec.ts
Normal file
22
App/tests/staging/issue-04-po-date-field.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
24
App/tests/staging/issue-05-approved-date-as-po-date.spec.ts
Normal file
24
App/tests/staging/issue-05-approved-date-as-po-date.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
35
App/tests/staging/issue-06-closed-list-filters.spec.ts
Normal file
35
App/tests/staging/issue-06-closed-list-filters.spec.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
25
App/tests/staging/issue-10-attachments-grouped.spec.ts
Normal file
25
App/tests/staging/issue-10-attachments-grouped.spec.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
18
App/tests/staging/issue-104-history-pagination.spec.ts
Normal file
18
App/tests/staging/issue-104-history-pagination.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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/);
|
||||||
|
});
|
||||||
27
App/tests/staging/issue-109-new-po-vendor-search.spec.ts
Normal file
27
App/tests/staging/issue-109-new-po-vendor-search.spec.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
22
App/tests/staging/issue-11-terms-catalogue.spec.ts
Normal file
22
App/tests/staging/issue-11-terms-catalogue.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
22
App/tests/staging/issue-12-approved-this-month-card.spec.ts
Normal file
22
App/tests/staging/issue-12-approved-this-month-card.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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));
|
||||||
|
});
|
||||||
26
App/tests/staging/issue-121-history-accounting-code.spec.ts
Normal file
26
App/tests/staging/issue-121-history-accounting-code.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { USERS, login } from "./helpers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #121 — PO History gains an Accounting Code filter. The control is the
|
||||||
|
* shared type-to-search combobox; picking a code and applying narrows the list
|
||||||
|
* via an `accountId` query param (mirrored by the CSV/PDF export links).
|
||||||
|
*/
|
||||||
|
test("#121 history can be filtered by accounting code", async ({ page }) => {
|
||||||
|
await login(page, USERS.MANAGER);
|
||||||
|
await page.goto("/history");
|
||||||
|
|
||||||
|
// The Accounting Code control is present (its trigger shows the empty label).
|
||||||
|
const trigger = page.getByRole("button", { name: "All accounting codes" });
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
|
||||||
|
// Open it and pick the first accounting code from the searchable list.
|
||||||
|
await trigger.click();
|
||||||
|
await expect(page.getByPlaceholder("Type code or name…")).toBeVisible();
|
||||||
|
await page.locator(".max-h-72 button").first().click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Apply" }).click();
|
||||||
|
|
||||||
|
// Applying the filter drives an accountId query param.
|
||||||
|
await expect(page).toHaveURL(/accountId=/);
|
||||||
|
});
|
||||||
20
App/tests/staging/issue-13-payments-this-month-card.spec.ts
Normal file
20
App/tests/staging/issue-13-payments-this-month-card.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
21
App/tests/staging/issue-14-email-to-vendor.spec.ts
Normal file
21
App/tests/staging/issue-14-email-to-vendor.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
18
App/tests/staging/issue-24-40-logout-tooltip.spec.ts
Normal file
18
App/tests/staging/issue-24-40-logout-tooltip.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
27
App/tests/staging/issue-26-41-total-po-card.spec.ts
Normal file
27
App/tests/staging/issue-26-41-total-po-card.spec.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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/);
|
||||||
|
});
|
||||||
21
App/tests/staging/issue-31-history-multi-status.spec.ts
Normal file
21
App/tests/staging/issue-31-history-multi-status.spec.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
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}`));
|
||||||
|
});
|
||||||
15
App/tests/staging/issue-44-line-item-units.spec.ts
Normal file
15
App/tests/staging/issue-44-line-item-units.spec.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
18
App/tests/staging/issue-50-rupee-compact-format.spec.ts
Normal file
18
App/tests/staging/issue-50-rupee-compact-format.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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)?/);
|
||||||
|
});
|
||||||
38
App/tests/staging/issue-53-cancel-po-modal.spec.ts
Normal file
38
App/tests/staging/issue-53-cancel-po-modal.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
22
App/tests/staging/issue-57-vendor-search-catalogue.spec.ts
Normal file
22
App/tests/staging/issue-57-vendor-search-catalogue.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
31
App/tests/staging/issue-96-sidebar-collapsible.spec.ts
Normal file
31
App/tests/staging/issue-96-sidebar-collapsible.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
36
App/tests/unit/history-filters.test.tsx
Normal file
36
App/tests/unit/history-filters.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
// HistoryFilters reads the URL via next/navigation; mock both hooks it uses.
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||||
|
useSearchParams: () => new URLSearchParams(""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HistoryFilters } from "@/app/(portal)/history/history-filters";
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
vessels: [{ id: "v1", name: "Vessel One" }],
|
||||||
|
accounts: [],
|
||||||
|
perPageOptions: [25, 50, 100],
|
||||||
|
defaultPerPage: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("HistoryFilters", () => {
|
||||||
|
// Regression guard for issue #136: the "Approved From" / "Approved To" date
|
||||||
|
// filters were removed from PO History. They must not reappear.
|
||||||
|
it("does not render the Approved From / Approved To filters", () => {
|
||||||
|
render(<HistoryFilters {...props} />);
|
||||||
|
expect(screen.queryByText("Approved From")).toBeNull();
|
||||||
|
expect(screen.queryByText("Approved To")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders the remaining filters (created-date range, cost centre, accounting code, status)", () => {
|
||||||
|
render(<HistoryFilters {...props} />);
|
||||||
|
expect(screen.getByText("From")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("To")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Cost Centre")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Accounting Code")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Status")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
App/tests/unit/pdf-export-auth.test.ts
Normal file
26
App/tests/unit/pdf-export-auth.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
55
App/tests/unit/project-code-field.test.tsx
Normal file
55
App/tests/unit/project-code-field.test.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ProjectCodeField } from "@/components/po/project-code-field";
|
||||||
|
|
||||||
|
const OPTIONS = ["Petronet LNG Cochin", "Haldia Reach", "COMACOE Mandvi"];
|
||||||
|
|
||||||
|
function options(container: HTMLElement) {
|
||||||
|
return Array.from(container.querySelectorAll("option")).map((o) => ({
|
||||||
|
value: o.getAttribute("value"),
|
||||||
|
text: o.textContent,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectCodeField", () => {
|
||||||
|
it("renders a select named projectCode with an empty option + every supplied code", () => {
|
||||||
|
const { container } = render(<ProjectCodeField options={OPTIONS} />);
|
||||||
|
const select = container.querySelector("select");
|
||||||
|
expect(select?.getAttribute("name")).toBe("projectCode");
|
||||||
|
|
||||||
|
const opts = options(container);
|
||||||
|
// empty "none" option first, then exactly the supplied codes
|
||||||
|
expect(opts[0].value).toBe("");
|
||||||
|
expect(opts.slice(1).map((o) => o.value)).toEqual(OPTIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a current value that is one of the options (no duplicate option)", () => {
|
||||||
|
const { container } = render(<ProjectCodeField options={OPTIONS} current="Haldia Reach" />);
|
||||||
|
const select = container.querySelector("select") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("Haldia Reach");
|
||||||
|
// only the options + empty option — no extra "(current)" entry
|
||||||
|
expect(container.querySelectorAll("option")).toHaveLength(OPTIONS.length + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a legacy current value not in the list as a leading (current) option", () => {
|
||||||
|
const { container } = render(<ProjectCodeField options={OPTIONS} current="Legacy Project X" />);
|
||||||
|
const select = container.querySelector("select") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("Legacy Project X");
|
||||||
|
expect(screen.getByText("Legacy Project X (current)")).toBeInTheDocument();
|
||||||
|
// empty + (current) + options
|
||||||
|
expect(container.querySelectorAll("option")).toHaveLength(OPTIONS.length + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the empty option when no current value is given", () => {
|
||||||
|
const { container } = render(<ProjectCodeField options={OPTIONS} current={null} />);
|
||||||
|
const select = container.querySelector("select") as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders just the empty option when no codes are configured", () => {
|
||||||
|
const { container } = render(<ProjectCodeField options={[]} />);
|
||||||
|
const opts = options(container);
|
||||||
|
expect(opts).toHaveLength(1);
|
||||||
|
expect(opts[0].value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
parseSel,
|
parseSel,
|
||||||
toggleSel,
|
toggleSel,
|
||||||
allocatePoSpend,
|
allocatePoSpend,
|
||||||
|
accountLeafIds,
|
||||||
|
periodRange,
|
||||||
type ReportDataset,
|
type ReportDataset,
|
||||||
type AccountNode,
|
type AccountNode,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
|
|
@ -49,6 +51,49 @@ const DS: ReportDataset = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("accountLeafIds (report → PO drill-down)", () => {
|
||||||
|
const RAW = ACCOUNTS.map((a) => ({ id: a.id, parentId: a.parentId }));
|
||||||
|
|
||||||
|
it("expands a heading to every leaf underneath it", () => {
|
||||||
|
expect(accountLeafIds(RAW, "H").sort()).toEqual(["L1", "L2"]);
|
||||||
|
expect(accountLeafIds(RAW, "S").sort()).toEqual(["L1", "L2"]);
|
||||||
|
});
|
||||||
|
it("returns a leaf node as itself", () => {
|
||||||
|
expect(accountLeafIds(RAW, "L1")).toEqual(["L1"]);
|
||||||
|
});
|
||||||
|
it("returns [] for an unknown id", () => {
|
||||||
|
expect(accountLeafIds(RAW, "nope")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("periodRange (report → PO History approved window)", () => {
|
||||||
|
it("monthly → the whole selected FY (Apr–Mar)", () => {
|
||||||
|
expect(periodRange("monthly", 2025, 0, [2024, 2025])).toEqual({
|
||||||
|
from: "2025-04-01",
|
||||||
|
to: "2026-03-31",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("yearly → the full span of FYs in the dataset", () => {
|
||||||
|
expect(periodRange("yearly", 2025, 0, [2024, 2025])).toEqual({
|
||||||
|
from: "2024-04-01",
|
||||||
|
to: "2026-03-31",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("weekly → the focused FY month (Apr=0)", () => {
|
||||||
|
expect(periodRange("weekly", 2025, 0, [2025])).toEqual({
|
||||||
|
from: "2025-04-01",
|
||||||
|
to: "2025-04-30",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("weekly → a Jan–Mar month rolls into the next calendar year", () => {
|
||||||
|
// FY-month index 9 = Jan, which belongs to calendar year fy+1.
|
||||||
|
expect(periodRange("weekly", 2025, 9, [2025])).toEqual({
|
||||||
|
from: "2026-01-01",
|
||||||
|
to: "2026-01-31",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("financial-year helpers", () => {
|
describe("financial-year helpers", () => {
|
||||||
it("maps Apr–Mar to the Indian FY start year", () => {
|
it("maps Apr–Mar to the Indian FY start year", () => {
|
||||||
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
|
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
|
||||||
|
|
|
||||||
94
App/tests/unit/unsaved-changes-guard.test.tsx
Normal file
94
App/tests/unit/unsaved-changes-guard.test.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
App/tests/unit/vendor-form-captcha.test.tsx
Normal file
96
App/tests/unit/vendor-form-captcha.test.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -4,6 +4,15 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Reports — Purchasing spend analytics** (`view_analytics`: Manager / SuperUser / Auditor / Admin) — `/reports/cost-centres` and `/reports/accounting-codes`, each an index → drill-down → detail. KPI tiles, comparison + trend charts (one colour per item), Top-N tables, per-row sparklines, and CSV export; URL-driven filters (granularity Weekly / Monthly / Yearly, financial year, Top/Bottom-N, an "Add to graph" custom comparison). Spend = post-approval POs by `approvedAt`/`totalAmount`, allocated across each PO's line-item accounting codes. Pure, unit-tested core in `lib/reports.ts`.
|
||||||
|
- **Report → PO drill-down** (#126) — the Cost Centre and Accounting Code report detail pages gain a **"View POs"** link that opens **PO History** pre-filtered to that cost centre / accounting code and the period currently in view (mapped to the approved-date window, since spend is dated by `approvedAt`). PO History gains an **Accounting Code** filter that accepts any tree node and matches a PO whose PO-level account **or** any line-item account falls under that node's leaves. The History page and its CSV/PDF export share one `buildPoHistoryWhere` builder so they never diverge.
|
||||||
|
- **Email PO to vendor** (issue #14) — one-click Outlook draft to the vendor's primary contact with a **7-day download link** to the PO PDF. Rendered by the new **PdfService** microservice (Express + Playwright → headless Chromium) and stored in R2; the PDF is **cached per PO**, so repeat sends reuse the copy and only refresh the link.
|
||||||
|
- **Microservices** — `EpfoService` (UAN / EPFO assisted-lookup proxy; live portal nav stubbed behind `EPFO_LIVE`) and `PdfService` (PO → PDF) join `GstService`. All three are **auto-deployed on each release tag** via the root `ecosystem.config.js` + `deploy.yml` (`pm2 startOrReload … --update-env`).
|
||||||
|
- **Unsaved-changes prompt** (issue #18) — leaving the PO create/edit screen with unsaved edits offers **Save as draft / Discard / Stay** (in-app navigation) or the browser's native warning (refresh / close).
|
||||||
|
- **Crew login on hire** (crewing, feature-flagged) — onboarding, direct placement, and admin crew-create accept an explicit **login email + initial password** for management ranks (`Rank.grantsLogin`), creating the `SITE_STAFF` login in one step.
|
||||||
|
- **Delivery Locations** (issue #19) — admin-managed `Company`+address list backing the PO "Place of Delivery" dropdown, gated by `manage_delivery_locations` (Manager / SuperUser / Admin).
|
||||||
|
- **Terms & Conditions catalogue** (issue #11) — admin-managed, user-defined T&C categories + clauses feeding a dynamic PO terms editor; the chosen rows are a JSON snapshot on `PurchaseOrder.terms`.
|
||||||
|
- **Advance payment on approval** (issue #92) — the approving Manager sets how much is paid first; the resolved absolute amount is stored on `PurchaseOrder.suggestedAdvancePayment` and prefills the first Accounts payment.
|
||||||
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
|
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
|
||||||
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
|
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
|
||||||
- **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
|
- **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
|
||||||
|
|
@ -29,4 +38,6 @@
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **"Email to vendor" never rendered a real PDF** (issue #14) — the auth middleware redirected PdfService's unauthenticated `svc`-token export fetch to `/login` before the route's token check ran, so the bypass never executed. `/api/po/<id>/export` is now allowed through when its `svc` token matches `PDF_SERVICE_TOKEN` (`lib/pdf-export-auth.ts`); everything else stays auth-gated.
|
||||||
|
- **Reports comparison charts all rendered one colour** — `SERIES_COLORS` lived in a `"use client"` module and was imported by the server-component report pages, where a plain value becomes a client-reference proxy (so `SERIES_COLORS[i]` was `undefined` and recharts fell back to its default stroke). Moved the palette to a dependency-free shared module (`lib/report-colors.ts`).
|
||||||
- Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README).
|
- Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README).
|
||||||
|
|
|
||||||
132
Docs/TESTING.md
Normal file
132
Docs/TESTING.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# 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.
|
||||||
54
PdfService/README.md
Normal file
54
PdfService/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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).
|
||||||
|
|
@ -60,6 +60,11 @@ requires an interactive ESLint migration (a follow-up). Integration tests are
|
||||||
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
|
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
|
||||||
(not in this shared CI, to avoid prod-mirror schema drift).
|
(not in this shared CI, to avoid prod-mirror schema drift).
|
||||||
|
|
||||||
|
The **issue watcher pre-applies gate 1 (test-presence) locally** before opening a PR:
|
||||||
|
if Claude's fix changes code under `App/app|lib|components|hooks` but adds no test, the
|
||||||
|
watcher does **not** open a PR — it marks the issue `claude-failed` and comments — so it
|
||||||
|
never raises a PR that this CI would immediately reject. Re-queue (`claude-queue`) to retry.
|
||||||
|
|
||||||
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
|
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
@ -70,6 +75,7 @@ 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`) |
|
| 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 (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) |
|
| 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`) |
|
| 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 |
|
| 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` |
|
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
|
||||||
|
|
@ -93,6 +99,91 @@ 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;
|
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.
|
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).
|
||||||
|
- **Bounded + detached run:** each Claude invocation is wrapped in `setsid timeout`
|
||||||
|
(`claudeTimeout`, default `30m`). `setsid` detaches it from any terminal (so a manual
|
||||||
|
run can't leave your shell stuck on a lingering child); `timeout` guarantees control
|
||||||
|
returns to the supervisor — so even a stuck/misbehaving run still gets its commits
|
||||||
|
pushed and its `handled:` marker written, and can never wedge the flock lock for later
|
||||||
|
cron runs. Runtime checks use **port `3101`** (`devPort`), distinct from the issue
|
||||||
|
watcher's `3100`, and the watcher reaps that port after every run.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**Updating the deployed copy:** `update-pr-review-watcher.sh` refreshes the watcher
|
||||||
|
script in one command, from a dedicated self-update checkout (`~/pr-review-watcher/.src`)
|
||||||
|
that never races the issue watcher's clone. Copy it once, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp automation/update-pr-review-watcher.sh ~/pr-review-watcher/ # one-time
|
||||||
|
~/pr-review-watcher/update-pr-review-watcher.sh # pull from master
|
||||||
|
~/pr-review-watcher/update-pr-review-watcher.sh some/branch # or a branch (pre-merge testing)
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads the live config for the token/URL, never clobbers the config, and self-updates.
|
||||||
|
|
||||||
## Test database (for autofix verification)
|
## Test database (for autofix verification)
|
||||||
|
|
||||||
So the fix stage can verify against realistic data without touching production:
|
So the fix stage can verify against realistic data without touching production:
|
||||||
|
|
@ -135,6 +226,11 @@ before a release tag deploys them to prod.
|
||||||
- A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by
|
- A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by
|
||||||
`NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing
|
`NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing
|
||||||
when the var is unset, so production is unaffected).
|
when the var is unset, so production is unaffected).
|
||||||
|
- **Feature flags on staging:** `staging-up.sh` enables
|
||||||
|
`NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true`, so submitters (TECHNICAL/MANNING) can
|
||||||
|
read every PO and open the History page here (read-only) for testing ahead of a prod
|
||||||
|
rollout. The line is appended idempotently, so already-provisioned staging `.env`s pick
|
||||||
|
it up on the next refresh.
|
||||||
- Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`.
|
- Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`.
|
||||||
|
|
||||||
## Issue label lifecycle
|
## Issue label lifecycle
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,22 @@ while [ "$f" -lt "$n_fix" ]; do
|
||||||
|
|
||||||
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
|
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
|
||||||
if [ "$commits" -gt 0 ]; then
|
if [ "$commits" -gt 0 ]; then
|
||||||
|
# Test-presence gate -- mirror .forgejo/workflows/pr-checks.yml so the watcher
|
||||||
|
# never opens a PR the CI will immediately reject. "Code" = app source under
|
||||||
|
# App/(app|lib|components|hooks); tests, prisma, config, docs are exempt.
|
||||||
|
changed=$(git -C "$WORKDIR" diff --name-only "origin/$BASE_BRANCH...HEAD")
|
||||||
|
code_changed=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' | grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
|
||||||
|
test_changed=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
|
||||||
|
if [ -n "$code_changed" ] && [ -z "$test_changed" ]; then
|
||||||
|
log "Test-presence gate FAILED for #$num: code changed with no test; not opening a PR"
|
||||||
|
set_labels "$num" "claude-working" "claude-failed"
|
||||||
|
add_comment "$num" "$BOT_MARKER
|
||||||
|
[Claude] Implemented a change but added **no test**, so no PR was opened. The contribution policy (\`pr-checks.yml\`) requires a test for any change under \`App/app|lib|components|hooks\`, and would reject this. Re-add \`claude-queue\` to retry, or pick it up interactively.
|
||||||
|
|
||||||
|
Code files that needed an accompanying test:
|
||||||
|
$code_changed"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
log "Claude made $commits commit(s); pushing $branch"
|
log "Claude made $commits commit(s); pushing $branch"
|
||||||
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
|
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
|
||||||
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue
|
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue
|
||||||
|
|
|
||||||
308
automation/claude-pr-review-watcher.sh
Normal file
308
automation/claude-pr-review-watcher.sh
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
#!/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')
|
||||||
|
# Hard wall-clock cap on a single Claude run. --max-turns bounds turns but a single
|
||||||
|
# stuck turn (or a server Claude spawned) can still block forever -- which under cron
|
||||||
|
# would hold the flock lock and freeze every later run. `timeout` guarantees control
|
||||||
|
# returns to the supervisor so it can still push partial work + write the handled marker.
|
||||||
|
CLAUDE_TIMEOUT=$(cfg '.claudeTimeout // "30m"')
|
||||||
|
# Ephemeral dev-server port for Claude's runtime checks. DISTINCT from the issue
|
||||||
|
# watcher's 3100 so the two never collide if their cron runs overlap (3000=prod,
|
||||||
|
# 3100=autofix/issue-watcher, 3200=staging, 3101=this watcher).
|
||||||
|
DEV_PORT=$(cfg '.devPort // 3101')
|
||||||
|
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 $DEV_PORT ONLY:"
|
||||||
|
printf '%s\n' " cd App && pnpm dev -p $DEV_PORT (production runs on 3000 -- NEVER touch 3000)"
|
||||||
|
printf '%s\n' " Stop ONLY your own server by port ('fuser -k $DEV_PORT/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' "7. BEFORE you finish: stop any dev server you started ('fuser -k $DEV_PORT/tcp'), leave NO"
|
||||||
|
printf '%s\n' " background process running, and then END YOUR TURN. Do not wait or keep the session open."
|
||||||
|
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, timeout: $CLAUDE_TIMEOUT)"
|
||||||
|
# `timeout` sends TERM at the limit, then KILL 30s later, in its own session
|
||||||
|
# (-s/setsid via `setsid`) so the whole process group dies -- not just `claude`,
|
||||||
|
# but any dev server it spawned. Bounded so a stuck run can never wedge the lock.
|
||||||
|
( cd "$WORKDIR" && setsid timeout -k 30s "$CLAUDE_TIMEOUT" \
|
||||||
|
"$CLAUDE" -p --dangerously-skip-permissions \
|
||||||
|
--max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$?
|
||||||
|
if [ "$rc" -eq 124 ]; then
|
||||||
|
log " Claude TIMED OUT after $CLAUDE_TIMEOUT on PR #$num (rc=124) -- continuing with any committed work"
|
||||||
|
else
|
||||||
|
log " Claude exited with code $rc for PR #$num"
|
||||||
|
fi
|
||||||
|
# Backstop: reap a dev server the run may have left on this watcher's dev port.
|
||||||
|
fuser -k "$DEV_PORT/tcp" >/dev/null 2>&1 || true
|
||||||
|
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."
|
||||||
15
automation/pr-review-watcher.config.example.json
Normal file
15
automation/pr-review-watcher.config.example.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"claudeTimeout": "30m",
|
||||||
|
"devPort": 3101
|
||||||
|
}
|
||||||
|
|
@ -74,3 +74,21 @@ if [ -n "$MIG_DIR" ]; then
|
||||||
else
|
else
|
||||||
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
|
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
|
||||||
fi
|
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
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,20 @@ AZURE_AD_CLIENT_SECRET="dev-placeholder"
|
||||||
AZURE_AD_TENANT_ID="dev-placeholder"
|
AZURE_AD_TENANT_ID="dev-placeholder"
|
||||||
DATABASE_URL="$TEST_URL"
|
DATABASE_URL="$TEST_URL"
|
||||||
GST_SERVICE_URL="http://localhost:3003"
|
GST_SERVICE_URL="http://localhost:3003"
|
||||||
|
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
|
||||||
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
||||||
PORT=$PORT
|
PORT=$PORT
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$DIR/App/.env"
|
chmod 600 "$DIR/App/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure feature flags are present on already-provisioned staging envs too (the
|
||||||
|
# .env above is written only once, so a flag added later won't appear without
|
||||||
|
# this). Let submitters (TECHNICAL/MANNING) read all POs + open History on staging.
|
||||||
|
if ! grep -qE '^NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=' "$DIR/App/.env"; then
|
||||||
|
printf 'NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true\n' >> "$DIR/App/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
|
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
|
||||||
# Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel
|
# Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel
|
||||||
# (ssh -L 3200:localhost:3200 ...), never directly from the public internet.
|
# (ssh -L 3200:localhost:3200 ...), never directly from the public internet.
|
||||||
|
|
|
||||||
40
automation/update-pr-review-watcher.sh
Normal file
40
automation/update-pr-review-watcher.sh
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the deployed PR-review watcher from the repo, in one command.
|
||||||
|
#
|
||||||
|
# ~/pr-review-watcher/update-pr-review-watcher.sh # from master (default)
|
||||||
|
# ~/pr-review-watcher/update-pr-review-watcher.sh some/branch # from a branch (pre-merge testing)
|
||||||
|
#
|
||||||
|
# Pulls the latest script into a dedicated self-update checkout (~/pr-review-watcher/.src),
|
||||||
|
# separate from any work clone so it never races the issue watcher, then copies the
|
||||||
|
# watcher (and this updater) into place. NEVER touches the live config (real token).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG="$HERE/pr-review-watcher.config.json"
|
||||||
|
[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (deploy the watcher first)"; exit 1; }
|
||||||
|
REF="${1:-master}"
|
||||||
|
SRC="$HERE/.src"
|
||||||
|
|
||||||
|
cfg() { jq -r "$1" "$CONFIG"; }
|
||||||
|
URL=$(cfg .forgejoUrl); REPO=$(cfg .repo); TOKEN=$(cfg .token)
|
||||||
|
host=${URL#*://}; scheme=${URL%%://*}; owner=${REPO%%/*}
|
||||||
|
CLONE="${scheme}://${owner}:${TOKEN}@${host}/${REPO}.git"
|
||||||
|
|
||||||
|
if [ ! -d "$SRC/.git" ]; then
|
||||||
|
echo "First run: cloning $REPO into $SRC"
|
||||||
|
git clone -q "$CLONE" "$SRC"
|
||||||
|
fi
|
||||||
|
git -C "$SRC" remote set-url origin "$CLONE" # keep the token fresh if it was rotated
|
||||||
|
git -C "$SRC" fetch origin -q --prune
|
||||||
|
# Prefer the remote-tracking ref; fall back to a literal ref (tag) if not a branch.
|
||||||
|
git -C "$SRC" checkout -f -q "origin/$REF" 2>/dev/null || git -C "$SRC" checkout -f -q "$REF"
|
||||||
|
git -C "$SRC" clean -fdq
|
||||||
|
|
||||||
|
cp "$SRC/automation/claude-pr-review-watcher.sh" "$HERE/"
|
||||||
|
cp "$SRC/automation/update-pr-review-watcher.sh" "$HERE/" 2>/dev/null || true # self-update
|
||||||
|
# Seed the config from the example ONLY if missing -- never clobber the real token.
|
||||||
|
[ -f "$CONFIG" ] || cp "$SRC/automation/pr-review-watcher.config.example.json" "$CONFIG"
|
||||||
|
|
||||||
|
echo "Updated from '$REF' ($(git -C "$SRC" rev-parse --short HEAD)). Watcher script is current."
|
||||||
|
echo "Dry-run: $HERE/claude-pr-review-watcher.sh $CONFIG"
|
||||||
Loading…
Add table
Reference in a new issue