Compare commits
1 commit
master
...
feat/terms
| Author | SHA1 | Date | |
|---|---|---|---|
| 5764403f1c |
94 changed files with 284 additions and 4817 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,10 +32,6 @@ automation/watcher.config.json
|
|||
automation/logs/
|
||||
automation/.watcher.lock
|
||||
|
||||
# Claude PR-review-comment watcher (real token + lock stay local; shares logs/)
|
||||
automation/pr-review-watcher.config.json
|
||||
automation/.pr-review-watcher.lock
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
1
App/.gitignore
vendored
1
App/.gitignore
vendored
|
|
@ -13,7 +13,6 @@
|
|||
# Testing
|
||||
/coverage
|
||||
/playwright-report
|
||||
/playwright-report-staging
|
||||
/test-results
|
||||
/blob-report
|
||||
|
||||
|
|
|
|||
|
|
@ -113,14 +113,6 @@ Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a *
|
|||
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
|
||||
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
|
||||
|
||||
### Unsaved-changes prompt (issue #18)
|
||||
|
||||
The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `<UnsavedChangesGuard>` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and:
|
||||
- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
|
||||
- **In-app navigations** (sidebar / header / any internal `<a>`) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**.
|
||||
|
||||
`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept).
|
||||
|
||||
### PO Numbering (`lib/po-number.ts`)
|
||||
|
||||
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (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.
|
||||
|
|
@ -141,33 +133,14 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once
|
|||
|
||||
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
|
||||
|
||||
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey` → `po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
|
||||
|
||||
### Inventory (feature-flagged)
|
||||
|
||||
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
|
||||
|
||||
### Product catalogue sync (`lib/product-catalog.ts`)
|
||||
|
||||
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/catalogue/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
|
||||
|
||||
### Import → Closed
|
||||
|
||||
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
|
||||
|
||||
### Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
|
||||
|
||||
Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** subheading, so other domains can add report groups later). Gated by **`view_analytics`** (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an **index → drill/detail** pair:
|
||||
|
||||
- **Cost Centres** (`/reports/cost-centres`) — spend compared across **vessels** (the PO cost centre). Row → **`/reports/cost-centres/[id]`** detail: trend + a **Top accounting codes** breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N.
|
||||
- **Accounting Codes** (`/reports/accounting-codes`) — drills the `Account` tree (headings → sub-headings → leaves) via a `?parent=` query; leaf rows open **`/reports/accounting-codes/[id]`**: trend + breakdown **by cost centre** (or, for a non-leaf, by sub-account).
|
||||
|
||||
**Spend definition** (`lib/reports.ts`, the pure/unit-tested core): a PO counts once it reaches `POST_APPROVAL_STATUSES`, dated by `approvedAt`, valued at the full `totalAmount` — the same basis as the dashboard tiles. FY is the Indian **Apr–Mar** year. `getReportDataset()` does one query pass; everything else is pure functions over it. **`allocatePoSpend()`** splits each PO across the accounting codes its **line items** carry (line `accountId`, falling back to the PO-level account), **proportionally** so the per-PO rows always sum back to `totalAmount` — so multi-account POs are attributed correctly in the accounting-code report. `poCount` is **distinct POs** (a multi-account PO yields several rows). Account spend rolls leaf descendants up via `buildAccountIndex().leavesUnder`.
|
||||
|
||||
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (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).
|
||||
|
||||
Sites are **not** cost centres (only vessels are).
|
||||
|
||||
### Crewing (feature-flagged)
|
||||
|
||||
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
|
|||
import { distanceKm, formatDistance } from "@/lib/geo";
|
||||
import { ToggleProductButton, EditProductButton } from "../product-form";
|
||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
|
||||
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
|
||||
import { SiteSelect } from "@/components/inventory/site-select";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
|
|||
export function ProductsTable({
|
||||
products,
|
||||
canManage,
|
||||
detailBase = "/catalogue/items",
|
||||
detailBase = "/inventory/items",
|
||||
}: {
|
||||
products: ProductRow[];
|
||||
canManage: boolean;
|
||||
|
|
|
|||
4
App/app/(portal)/admin/vendors/actions.ts
vendored
4
App/app/(portal)/admin/vendors/actions.ts
vendored
|
|
@ -95,7 +95,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
|
|||
});
|
||||
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +108,7 @@ export async function verifyVendor(vendorId: string): Promise<ActionResult> {
|
|||
|
||||
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
revalidatePath(`/admin/vendors/${vendorId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
113
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
113
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
|
|
@ -113,44 +113,6 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
|
||||
// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
|
||||
// handles Escape on the capture phase so closing it does NOT also close the underlying form.
|
||||
function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
|
||||
}
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
return () => document.removeEventListener("keydown", onKey, true);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">GSTIN CAPTCHA</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
||||
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||
const [name, setName] = useState(vendor?.name ?? "");
|
||||
|
|
@ -187,19 +149,13 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
|||
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
||||
});
|
||||
const data: GstResult & { error?: string } = await res.json();
|
||||
// Keep the popup open on error so the user sees it in context and can retry / get a new image.
|
||||
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
|
||||
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
|
||||
setName(data.tradeName || data.legalName);
|
||||
setAddress(data.address);
|
||||
if (data.pincode) setPincode(data.pincode);
|
||||
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
||||
setCaptchaStep("idle");
|
||||
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
|
||||
}
|
||||
|
||||
// Close the CAPTCHA popup without touching the vendor form fields.
|
||||
function closeCaptcha() {
|
||||
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
|
||||
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -227,46 +183,31 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
|||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||
</button>
|
||||
</div>
|
||||
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
|
||||
{captchaStep === "loading" ? (
|
||||
<p className="py-4 text-center text-sm text-neutral-500">Loading CAPTCHA…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
||||
{captchaB64 && (
|
||||
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="6 digits"
|
||||
disabled={captchaStep === "verifying"}
|
||||
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||
/>
|
||||
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
|
||||
</button>
|
||||
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
|
||||
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
|
||||
New image
|
||||
</button>
|
||||
</div>
|
||||
{gstError && <p className="text-xs text-danger-600">{gstError}</p>}
|
||||
<div className="flex justify-end border-t border-neutral-100 pt-3">
|
||||
<button type="button" onClick={closeCaptcha}
|
||||
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{captchaStep === "ready" && captchaB64 && (
|
||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
||||
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
||||
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="6 digits"
|
||||
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||
/>
|
||||
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
Verify
|
||||
</button>
|
||||
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
||||
New image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CaptchaPopup>
|
||||
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
|
||||
{captchaStep === "idle" && gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</p>}
|
||||
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { approvePoSchema } from "@/lib/validations/po";
|
||||
import { syncProductCatalog } from "@/lib/product-catalog";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
|
|
@ -85,12 +84,6 @@ export async function approvePo({
|
|||
revalidatePath(`/admin/sites/${siteId}`);
|
||||
}
|
||||
|
||||
// Register the line items in the product catalogue (/catalogue/items) on
|
||||
// approval, so an approved PO's items are immediately reusable in further POs.
|
||||
// Idempotent; payment re-syncs to refresh prices on the final figures.
|
||||
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
|
||||
revalidatePath("/catalogue/items");
|
||||
|
||||
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||
await notify({
|
||||
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { LineItemInput } from "@/lib/validations/po";
|
|||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||
|
|
@ -245,7 +244,14 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
|||
<section>
|
||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
||||
<label className={LABEL}>Vendor</label>
|
||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
|
||||
<option value="">No vendor selected</option>
|
||||
{vendors.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
{/* Line Items */}
|
||||
|
|
|
|||
|
|
@ -19,18 +19,12 @@ const STATUSES = [
|
|||
|
||||
interface Props {
|
||||
vessels: { id: string; name: string }[];
|
||||
perPageOptions: number[];
|
||||
defaultPerPage: number;
|
||||
}
|
||||
|
||||
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
||||
export function HistoryFilters({ vessels }: Props) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
|
||||
? Number(sp.get("perPage"))
|
||||
: defaultPerPage;
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||
|
|
@ -56,8 +50,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
);
|
||||
}
|
||||
|
||||
// Changing any filter resets to page 1; perPage is preserved across applies.
|
||||
function buildParams(nextPerPage: number) {
|
||||
function apply() {
|
||||
const params = new URLSearchParams();
|
||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||
if (dateTo) params.set("dateTo", dateTo);
|
||||
|
|
@ -65,24 +58,12 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||
if (vesselId) params.set("vesselId", vesselId);
|
||||
for (const s of statuses) params.append("status", s);
|
||||
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||
return params;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
router.push(`/history?${buildParams(perPage).toString()}`);
|
||||
}
|
||||
|
||||
function changePerPage(next: number) {
|
||||
router.push(`/history?${buildParams(next).toString()}`);
|
||||
router.push(`/history?${params.toString()}`);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||
const params = new URLSearchParams();
|
||||
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/history?${qs}` : "/history");
|
||||
router.push("/history");
|
||||
}
|
||||
|
||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||
|
|
@ -158,13 +139,6 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
|
|||
Clear
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label htmlFor="perPage" className="text-xs font-medium text-neutral-600">Per page</label>
|
||||
<select id="perPage" value={perPage} onChange={(e) => changePerPage(Number(e.target.value))}
|
||||
className="rounded-lg border border-neutral-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
||||
{perPageOptions.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,16 +6,12 @@ import Link from "next/link";
|
|||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { HistoryFilters } from "./history-filters";
|
||||
import { resolvePagination } from "@/lib/pagination";
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
export const metadata: Metadata = { title: "History" };
|
||||
|
||||
const PER_PAGE_OPTIONS = [25, 50, 100];
|
||||
const DEFAULT_PER_PAGE = 25;
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{
|
||||
dateFrom?: string;
|
||||
|
|
@ -24,8 +20,6 @@ interface Props {
|
|||
approvedTo?: string;
|
||||
vesselId?: string;
|
||||
status?: string | string[];
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +36,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
||||
await searchParams;
|
||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
||||
|
||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||
if (dateFrom || dateTo) {
|
||||
|
|
@ -70,45 +63,16 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||
|
||||
const total = await db.purchaseOrder.count({ where });
|
||||
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
||||
perPageParam,
|
||||
pageParam,
|
||||
total,
|
||||
options: PER_PAGE_OPTIONS,
|
||||
defaultPerPage: DEFAULT_PER_PAGE,
|
||||
});
|
||||
|
||||
const [orders, vessels] = await Promise.all([
|
||||
db.purchaseOrder.findMany({
|
||||
where,
|
||||
include: { submitter: true, vessel: true, account: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take,
|
||||
take: 200,
|
||||
}),
|
||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Shared filter params for the pagination footer links (everything except `page`).
|
||||
const pageParams = new URLSearchParams();
|
||||
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
||||
if (dateTo) pageParams.set("dateTo", dateTo);
|
||||
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||
for (const s of statuses) pageParams.append("status", s);
|
||||
pageParams.set("perPage", String(perPage));
|
||||
|
||||
const pageHref = (p: number) => {
|
||||
const params = new URLSearchParams(pageParams);
|
||||
params.set("page", String(p));
|
||||
return `/history?${params.toString()}`;
|
||||
};
|
||||
|
||||
const firstRow = total === 0 ? 0 : skip + 1;
|
||||
const lastRow = skip + orders.length;
|
||||
|
||||
const exportParams = new URLSearchParams({ format: "csv" });
|
||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||
|
|
@ -140,7 +104,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
</div>
|
||||
|
||||
<Suspense>
|
||||
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||
<HistoryFilters vessels={vessels} />
|
||||
</Suspense>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
|
|
@ -185,41 +149,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
||||
)}
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
|
||||
<span>
|
||||
Showing {firstRow}–{lastRow} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={pageHref(page - 1)}
|
||||
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||
Previous
|
||||
</span>
|
||||
)}
|
||||
<span className="text-neutral-500">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{page < totalPages ? (
|
||||
<Link
|
||||
href={pageHref(page + 1)}
|
||||
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||
Next
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{orders.length === 200 && (
|
||||
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results — refine filters to narrow results.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ export function CartView() {
|
|||
<p className="text-neutral-500 font-medium">Your cart is empty</p>
|
||||
<p className="text-sm text-neutral-400 mt-1 mb-6">Browse Items or Vendors to add line items</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
|
||||
<Link href="/catalogue/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
|
||||
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
|
||||
<Link href="/inventory/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -108,7 +108,7 @@ export function CartView() {
|
|||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
+ Add more items
|
||||
</Link>
|
||||
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
|||
|
||||
const { id } = await params;
|
||||
const { site: siteId } = await searchParams;
|
||||
const baseHref = `/catalogue/items/${id}`;
|
||||
const baseHref = `/inventory/items/${id}`;
|
||||
|
||||
const [product, sites] = await Promise.all([
|
||||
db.product.findUnique({
|
||||
|
|
@ -85,7 +85,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
|||
<div className="max-w-6xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/catalogue/items" className="hover:text-neutral-700">Items</Link>
|
||||
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
||||
</div>
|
||||
|
|
@ -108,7 +108,7 @@ export function ItemsTable({
|
|||
value={currentSiteId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
|
||||
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/items");
|
||||
}}
|
||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
|
|
@ -254,7 +254,7 @@ export function ItemsTable({
|
|||
<td className="px-12 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/catalogue/vendors/${vendor.vendorId}`}
|
||||
href={`/inventory/vendors/${vendor.vendorId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
||||
>
|
||||
|
|
@ -20,7 +20,7 @@ export default async function InventoryItemsPage() {
|
|||
},
|
||||
});
|
||||
|
||||
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/items
|
||||
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
|
||||
const canManage = hasPermission(session.user.role, "manage_products");
|
||||
|
||||
return (
|
||||
|
|
@ -48,7 +48,7 @@ export default async function InventoryVendorDetailPage({ params }: Props) {
|
|||
<div className="max-w-5xl space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<Link href="/catalogue/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||
<span>/</span>
|
||||
<span className="text-neutral-900 font-medium">{vendor.name}</span>
|
||||
</div>
|
||||
|
|
@ -68,7 +68,7 @@ export function VendorsTable({
|
|||
value={currentSiteId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
|
||||
router.push(id ? `/inventory/vendors?siteId=${id}` : "/inventory/vendors");
|
||||
}}
|
||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
>
|
||||
|
|
@ -149,7 +149,7 @@ export function VendorsTable({
|
|||
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
{vendor.name}
|
||||
</Link>
|
||||
{vendor.vendorId && (
|
||||
|
|
@ -4,12 +4,107 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { processPaymentSchema } from "@/lib/validations/po";
|
||||
import { syncProductCatalog } from "@/lib/product-catalog";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
function nameToCode(name: string): string {
|
||||
const slug = name.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 20);
|
||||
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
|
||||
}
|
||||
|
||||
// Sync product catalog after payment is confirmed:
|
||||
// - Auto-create products for unlinked line items (matched by name or brand new)
|
||||
// - Upsert per-vendor prices for all items
|
||||
async function syncProductCatalog(
|
||||
poId: string,
|
||||
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
|
||||
vendorId: string | null,
|
||||
actorId: string
|
||||
) {
|
||||
const updatedProductIds: string[] = [];
|
||||
|
||||
for (const li of lineItems) {
|
||||
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
|
||||
let productId = li.productId;
|
||||
let priceChanged = false;
|
||||
|
||||
if (!productId) {
|
||||
// Try to find an existing product by name (case-insensitive)
|
||||
const existing = await db.product.findFirst({
|
||||
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
|
||||
select: { id: true, lastPrice: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
productId = existing.id;
|
||||
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
|
||||
} else {
|
||||
// Create a new product — first-time registration, not a price update
|
||||
const code = nameToCode(li.name);
|
||||
try {
|
||||
const created = await db.product.create({
|
||||
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
|
||||
});
|
||||
productId = created.id;
|
||||
} catch {
|
||||
// Code collision (extremely unlikely) — add extra entropy
|
||||
const created = await db.product.create({
|
||||
data: {
|
||||
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
|
||||
name: li.name,
|
||||
lastPrice: unitPrice,
|
||||
lastVendorId: vendorId,
|
||||
},
|
||||
});
|
||||
productId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Link the line item to the product for future reference
|
||||
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
|
||||
} else {
|
||||
const current = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: { lastPrice: true },
|
||||
});
|
||||
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
|
||||
}
|
||||
|
||||
// Always update lastPrice / lastVendorId on the product
|
||||
await db.product.update({
|
||||
where: { id: productId },
|
||||
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
|
||||
});
|
||||
|
||||
// Upsert per-vendor price if PO has a vendor
|
||||
if (vendorId) {
|
||||
await db.productVendorPrice.upsert({
|
||||
where: { productId_vendorId: { productId, vendorId } },
|
||||
update: { price: unitPrice },
|
||||
create: { productId, vendorId, price: unitPrice },
|
||||
});
|
||||
}
|
||||
|
||||
if (priceChanged) updatedProductIds.push(productId);
|
||||
}
|
||||
|
||||
if (updatedProductIds.length > 0) {
|
||||
await db.pOAction.create({
|
||||
data: {
|
||||
actionType: "PRODUCT_PRICE_UPDATED",
|
||||
actorId,
|
||||
poId,
|
||||
metadata: { updatedProductIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
|
||||
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
|
|||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
|
||||
|
|
@ -70,8 +68,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const markDirty = () => setDirty(true);
|
||||
|
||||
const canSubmit = po.status === "DRAFT";
|
||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||
|
|
@ -99,7 +95,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
setError(result.error);
|
||||
setSubmitting(null);
|
||||
} else {
|
||||
setDirty(false); // saved — don't warn on the redirect
|
||||
router.push(`/po/${result.id}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +115,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
const extPo = po;
|
||||
|
||||
return (
|
||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||
{canResubmit && (
|
||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-warning-700">
|
||||
|
|
@ -184,7 +179,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
<SearchableSelect
|
||||
name="accountId"
|
||||
value={defaultAccountId}
|
||||
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||
onChange={setDefaultAccountId}
|
||||
groups={accounts}
|
||||
placeholder="Search accounting code…"
|
||||
required
|
||||
|
|
@ -250,7 +245,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||
onChange={setLineItems}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
|
|
@ -260,14 +255,21 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
{/* Vendor */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
|
||||
<option value="">No vendor selected</option>
|
||||
{vendors.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
{/* Terms & Conditions */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
||||
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
|
|
@ -296,12 +298,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UnsavedChangesGuard
|
||||
enabled={dirty && !submitting}
|
||||
onSaveDraft={() => handleSubmit("save")}
|
||||
saving={submitting === "save"}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage";
|
||||
import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
|
||||
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
|
||||
|
||||
type Result = { ok: true; mailto: string; to: string } | { error: string };
|
||||
|
|
@ -47,20 +47,13 @@ export async function prepareVendorEmail(poId: string): Promise<Result> {
|
|||
return { error: "PDF emailing is not configured on this environment." };
|
||||
}
|
||||
|
||||
// Render → store → presigned link. The PDF is cached at a deterministic
|
||||
// per-PO key: if a copy already exists and is at least as new as the PO's last
|
||||
// change, reuse it and only mint a fresh presigned URL (refreshing the 7-day
|
||||
// timer). Re-render only when there's no copy yet or the PO changed since.
|
||||
// Render → store → presigned link.
|
||||
let link: string;
|
||||
try {
|
||||
const pdf = await renderPoPdf(poId);
|
||||
const slug = po.poNumber.replace(/\//g, "-");
|
||||
const key = buildPoPdfKey(poId, `${slug}.pdf`);
|
||||
const cached = await statObject(key);
|
||||
const isFresh = cached !== null && cached.lastModified >= po.updatedAt;
|
||||
if (!isFresh) {
|
||||
const pdf = await renderPoPdf(poId);
|
||||
await uploadBuffer(key, pdf, "application/pdf");
|
||||
}
|
||||
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
|
||||
await uploadBuffer(key, pdf, "application/pdf");
|
||||
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
|
||||
} catch (e) {
|
||||
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export async function confirmReceipt({
|
|||
if (newStatus === "CLOSED" && po.vendorId) {
|
||||
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
}
|
||||
|
||||
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export async function importPo(
|
|||
if (resolvedVendorId) {
|
||||
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
|
||||
revalidatePath("/admin/vendors");
|
||||
revalidatePath("/catalogue/vendors");
|
||||
revalidatePath("/inventory/vendors");
|
||||
}
|
||||
|
||||
revalidatePath("/history");
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import type { Vendor } from "@prisma/client";
|
|||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { FileUploader } from "@/components/po/file-uploader";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
|
|
@ -43,14 +41,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||
);
|
||||
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [multiAccount, setMultiAccount] = useState(false);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const markDirty = () => setDirty(true);
|
||||
|
||||
async function handleSubmit(intent: "draft" | "submit") {
|
||||
setSubmitting(intent);
|
||||
|
|
@ -85,12 +82,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
return;
|
||||
}
|
||||
}
|
||||
setDirty(false); // saved — don't warn on the redirect
|
||||
router.push(`/po/${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Order Information */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||
|
|
@ -147,7 +143,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
<SearchableSelect
|
||||
name="accountId"
|
||||
value={defaultAccountId}
|
||||
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||
onChange={setDefaultAccountId}
|
||||
groups={accounts}
|
||||
placeholder="Search accounting code…"
|
||||
required
|
||||
|
|
@ -219,7 +215,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||
onChange={setLineItems}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
|
|
@ -233,7 +229,19 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||
Vendor (optional — can be added later)
|
||||
</label>
|
||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
|
||||
<select
|
||||
name="vendorId"
|
||||
value={vendorId}
|
||||
onChange={(e) => setVendorId(e.target.value)}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
<option value="">No vendor selected</option>
|
||||
{vendors.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -241,13 +249,13 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & 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>
|
||||
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} />
|
||||
</section>
|
||||
|
||||
{/* Attachments */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
|
||||
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
|
||||
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
|
|
@ -272,12 +280,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UnsavedChangesGuard
|
||||
enabled={dirty && !submitting}
|
||||
onSaveDraft={() => handleSubmit("draft")}
|
||||
saving={submitting === "draft"}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
|
||||
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Accounting Code — Reports" };
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
const tierBadgeCls: Record<string, string> = {
|
||||
Heading: "bg-primary-50 text-primary-700",
|
||||
"Sub-heading": "bg-violet-50 text-violet-700",
|
||||
Leaf: "bg-neutral-100 text-neutral-600",
|
||||
};
|
||||
|
||||
export default async function AccountingCodeDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; month?: string; break?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const node = idx.byId.get(id);
|
||||
if (!node) notFound();
|
||||
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const unit = yearly ? "year" : weekly ? "week" : "month";
|
||||
const leaf = idx.isLeaf(id);
|
||||
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
|
||||
const breakMode = leaf ? "cc" : sp.break === "cc" ? "cc" : "children";
|
||||
|
||||
const spend = accountNodeSpend(ds, idx, id, fy);
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const series = yearly
|
||||
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: spend.fyTotals[i] }))
|
||||
: weekly
|
||||
? WEEK_LABELS.map((w, i) => ({ label: w, value: accountNodeWeekly(ds, idx, id, fy, month)[i] }))
|
||||
: FY_MONTHS.map((m, i) => ({ label: m, value: spend.months[i] }));
|
||||
const total = sum(series.map((s) => s.value));
|
||||
const avg = series.length ? total / series.length : 0;
|
||||
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
|
||||
const nf = ds.fys.length;
|
||||
const yoy = nf >= 2 && spend.fyTotals[nf - 2] ? ((spend.fyTotals[nf - 1] - spend.fyTotals[nf - 2]) / spend.fyTotals[nf - 2]) * 100 : 0;
|
||||
|
||||
const childTier = idx.childrenOf(id)[0]?.tier ?? "Sub-heading";
|
||||
const breakdown = (breakMode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy)).slice(0, topn);
|
||||
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
|
||||
const breakLabel = breakMode === "cc" ? "Cost centre" : childTier;
|
||||
const breakTitle = breakMode === "cc" ? "Top cost centres" : "Composition by sub-account";
|
||||
|
||||
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
const base = `/reports/accounting-codes/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
if (weekly) p.set("month", String(month));
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
|
||||
|
||||
const path = idx.pathTo(id);
|
||||
const trail = [
|
||||
{ label: "Accounting Codes", href: `/reports/accounting-codes?fy=${fy}&gran=${gran}` },
|
||||
...path.map((a, i) => ({
|
||||
label: `${a.code} · ${a.name}`,
|
||||
href: i < path.length - 1 ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${a.id}` : undefined,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={trail} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
← Back to Accounting Codes
|
||||
</Link>
|
||||
|
||||
<ReportTitle
|
||||
title={`${node.code} · ${node.name}`}
|
||||
subtitle={`Aggregates all spend under this ${node.tier.toLowerCase()} · ${periodLabel}`}
|
||||
badge={<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tierBadgeCls[node.tier]}`}>{node.tier}</span>}
|
||||
/>
|
||||
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
|
||||
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
|
||||
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-neutral-900">{breakTitle}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!leaf && (
|
||||
<SegLink
|
||||
label="Break down by"
|
||||
options={[{ value: "children", label: `${childTier}s` }, { value: "cc", label: "Cost centres" }]}
|
||||
current={breakMode}
|
||||
hrefFor={(v) => q({ break: v, topn: sp.topn ?? "5" })}
|
||||
/>
|
||||
)}
|
||||
<SegLink
|
||||
label="Top"
|
||||
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
|
||||
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
|
||||
hrefFor={(v) => q({ break: breakMode, topn: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{breakdown.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-400">No spend to break down for {periodLabel}.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<BreakdownChart data={breakdown} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th className="py-2">{breakLabel}</th>
|
||||
<th className="py-2 text-right">Spend</th>
|
||||
<th className="py-2 text-right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{breakdown.map((b, i) => (
|
||||
<tr key={b.id}>
|
||||
<td className="py-2">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
|
||||
{b.label}
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
|
||||
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ChevronRight, BarChart3 } from "lucide-react";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
accountLevelRows,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
SCOPE_LABELS,
|
||||
type NodeSpend,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
|
||||
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Accounting Codes — Reports" };
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
const tierBadgeCls: Record<string, string> = {
|
||||
Heading: "bg-primary-50 text-primary-700",
|
||||
"Sub-heading": "bg-violet-50 text-violet-700",
|
||||
Leaf: "bg-neutral-100 text-neutral-600",
|
||||
};
|
||||
|
||||
export default async function AccountingCodesReport({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; parent?: string; sel?: string; cmp?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const scope = parseScope(sp.scope);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const sel = parseSel(sp.sel);
|
||||
const cmp = sp.cmp === "1" && sel.length > 0;
|
||||
|
||||
const parent = sp.parent && idx.byId.has(sp.parent) ? sp.parent : null;
|
||||
const parentNode = parent ? idx.byId.get(parent)! : null;
|
||||
|
||||
const rankOf = (r: NodeSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
|
||||
const sparkOf = (r: NodeSpend) => (yearly ? r.fyTotals : weekly ? accountNodeWeekly(ds, idx, r.node.id, fy, month) : r.months);
|
||||
|
||||
const ranked = cmp
|
||||
? sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }))
|
||||
: accountLevelRows(ds, idx, parent, fy);
|
||||
ranked.sort((a, b) => rankOf(b) - rankOf(a));
|
||||
const shown = cmp ? ranked : applyScope(ranked, scope);
|
||||
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
|
||||
const childTier = shown[0]?.node.tier ?? "Heading";
|
||||
const top = shown[0];
|
||||
|
||||
const nf = ds.fys.length;
|
||||
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
|
||||
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
|
||||
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
|
||||
|
||||
// One distinct colour per accounting code (series) in every granularity; the
|
||||
// x-axis is months / weeks / financial years.
|
||||
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
|
||||
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
|
||||
const row: Record<string, string | number> = { x: lab };
|
||||
shown.forEach((r) => (row[r.node.code] = sparkOf(r)[i]));
|
||||
return row;
|
||||
});
|
||||
const series: Series[] = shown.map((r, i) => ({ key: r.node.code, color: colored(i) }));
|
||||
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
|
||||
const base: Record<string, string | undefined> = {
|
||||
fy: String(fy),
|
||||
gran: gran === "monthly" ? undefined : gran,
|
||||
scope: scope === "top5" ? undefined : scope,
|
||||
month: weekly ? String(month) : undefined,
|
||||
};
|
||||
const qs = (extra: Record<string, string | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries({ ...base, ...extra })) if (v) p.set(k, v);
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
};
|
||||
const linkWith = (parentId: string | null) => `/reports/accounting-codes${qs({ parent: parentId ?? undefined, sel: sel.join(",") || undefined })}`;
|
||||
const detailHref = (id: string) => `/reports/accounting-codes/${id}${qs({ scope: undefined, parent: undefined })}`;
|
||||
const selHref = (id: string) => {
|
||||
const next = toggleSel(sel, id);
|
||||
return `/reports/accounting-codes${qs({ parent: cmp ? undefined : parent ?? undefined, sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
|
||||
};
|
||||
const rowHref = (r: NodeSpend) => (idx.isLeaf(r.node.id) ? detailHref(r.node.id) : linkWith(r.node.id));
|
||||
const exportHref = `/api/reports/spend?dim=accounting-code&fy=${fy}&gran=${gran}&scope=${scope}${parent && !cmp ? `&parent=${parent}` : ""}${cmp ? `&sel=${sel.join(",")}` : ""}`;
|
||||
|
||||
const trail = [{ label: "Accounting Codes", href: parent || cmp ? linkWith(null) : undefined }];
|
||||
if (parentNode && !cmp) {
|
||||
idx.pathTo(parentNode.id).forEach((a, i, arr) => trail.push({ label: `${a.code} · ${a.name}`, href: i < arr.length - 1 ? linkWith(a.id) : undefined }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={trail} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
scope={cmp ? undefined : scope}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
{cmp ? (
|
||||
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to browse
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{parentNode && (
|
||||
<Link
|
||||
href={linkWith(parentNode.parentId ?? null)}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
← Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"}
|
||||
</Link>
|
||||
)}
|
||||
{sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ parent: parent ?? undefined })} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReportTitle
|
||||
title={cmp ? "Custom comparison" : parentNode ? `${parentNode.code} · ${parentNode.name}` : "Accounting Codes"}
|
||||
subtitle={
|
||||
cmp
|
||||
? `Comparing ${shown.length} selected accounting codes. Untick a row to remove it.`
|
||||
: parentNode
|
||||
? `Comparing the ${childTier.toLowerCase()}s of ${parentNode.name}. Tick to graph, or click to ${childTier === "Leaf" ? "open its report" : "drill deeper"}.`
|
||||
: "Comparing top-level headings. Tick to graph, or click a heading to drill in."
|
||||
}
|
||||
/>
|
||||
|
||||
{grand === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
|
||||
No approved spend recorded for {periodLabel} yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
|
||||
<Kpi label={cmp ? "Selected" : `${childTier}s`} value={String(shown.length)} sub={cmp ? "in this graph" : `${SCOPE_LABELS[scope]} shown`} />
|
||||
<Kpi label="Highest spender" value={top ? top.node.code : "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-neutral-900">
|
||||
{yearly ? `Spend by ${childTier.toLowerCase()} — year over year` : weekly ? `Weekly spend by ${childTier.toLowerCase()}` : `Monthly spend by ${childTier.toLowerCase()}`}
|
||||
</p>
|
||||
<span className="text-xs text-neutral-400">{periodLabel}</span>
|
||||
</div>
|
||||
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
{shown.map((r) => {
|
||||
const value = rankOf(r);
|
||||
const pct = grand ? (value / grand) * 100 : 0;
|
||||
const leaf = idx.isLeaf(r.node.id);
|
||||
const inner = (
|
||||
<>
|
||||
<span className="w-14 shrink-0 font-mono text-xs text-neutral-500">{r.node.code}</span>
|
||||
<span className="flex-1 truncate text-sm font-medium text-neutral-900 group-hover:text-primary-700">{r.node.name}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${tierBadgeCls[r.node.tier]}`}>{r.node.tier}</span>
|
||||
<Sparkline values={sparkOf(r)} width={80} height={24} />
|
||||
<span className="w-28 text-right font-medium tabular-nums text-sm">{formatCurrency(value)}</span>
|
||||
<div className="hidden w-12 text-right tabular-nums text-xs text-neutral-500 md:block">{pct.toFixed(0)}%</div>
|
||||
{!cmp && (leaf ? <BarChart3 className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" /> : <ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div key={r.node.id} className="group flex items-center gap-3 border-b border-neutral-100 px-5 py-3 last:border-0 hover:bg-primary-50/40">
|
||||
<SelectCheckbox checked={sel.includes(r.node.id)} href={selHref(r.node.id)} />
|
||||
{cmp ? (
|
||||
<div className="flex flex-1 items-center gap-3 min-w-0">{inner}</div>
|
||||
) : (
|
||||
<Link href={rowHref(r)} className="flex flex-1 items-center gap-3 min-w-0">{inner}</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
topAccountsForCostCentre,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
type Tier,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
|
||||
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Cost Centre — Reports" };
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
const TIERS: Tier[] = ["Heading", "Sub-heading", "Leaf"];
|
||||
|
||||
export default async function CostCentreDetail({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ fy?: string; gran?: string; month?: string; tier?: string; topn?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const unit = yearly ? "year" : weekly ? "week" : "month";
|
||||
const tier: Tier = TIERS.includes(sp.tier as Tier) ? (sp.tier as Tier) : "Leaf";
|
||||
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
|
||||
|
||||
const row = costCentreRows(ds, fy).find((r) => r.id === id);
|
||||
if (!row) notFound();
|
||||
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const series = yearly
|
||||
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: row.fyTotals[i] }))
|
||||
: weekly
|
||||
? WEEK_LABELS.map((w, i) => ({ label: w, value: costCentreWeekly(ds, id, fy, month)[i] }))
|
||||
: FY_MONTHS.map((m, i) => ({ label: m, value: row.months[i] }));
|
||||
const total = sum(series.map((s) => s.value));
|
||||
const avg = series.length ? total / series.length : 0;
|
||||
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
|
||||
const nf = ds.fys.length;
|
||||
const yoy = nf >= 2 && row.fyTotals[nf - 2] ? ((row.fyTotals[nf - 1] - row.fyTotals[nf - 2]) / row.fyTotals[nf - 2]) * 100 : 0;
|
||||
|
||||
const breakdown = topAccountsForCostCentre(ds, idx, id, fy, tier).slice(0, topn);
|
||||
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
|
||||
|
||||
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
const base = `/reports/cost-centres/${id}`;
|
||||
const q = (extra: Record<string, string>) => {
|
||||
const p = new URLSearchParams({ fy: String(fy), gran });
|
||||
if (weekly) p.set("month", String(month));
|
||||
for (const [k, v] of Object.entries(extra)) p.set(k, v);
|
||||
return `${base}?${p.toString()}`;
|
||||
};
|
||||
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={[{ label: "Cost Centres", href: `/reports/cost-centres?fy=${fy}&gran=${gran}` }, { label: row.name }]} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to Cost Centres
|
||||
</Link>
|
||||
|
||||
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
|
||||
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
|
||||
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
|
||||
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
|
||||
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-neutral-900">Top accounting codes</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegLink label="Tier" options={TIERS.map((t) => ({ value: t, label: t }))} current={tier} hrefFor={(v) => q({ tier: v, topn: sp.topn ?? "5" })} />
|
||||
<SegLink
|
||||
label="Top"
|
||||
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
|
||||
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
|
||||
hrefFor={(v) => q({ tier, topn: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{breakdown.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-400">No spend at this tier for {periodLabel}.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<BreakdownChart data={breakdown} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th className="py-2">{tier}</th>
|
||||
<th className="py-2 text-right">Spend</th>
|
||||
<th className="py-2 text-right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{breakdown.map((b, i) => (
|
||||
<tr key={b.id}>
|
||||
<td className="py-2">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
|
||||
{b.label}
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
|
||||
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { formatCurrency, formatCompactINR } from "@/lib/utils";
|
||||
import {
|
||||
getReportDataset,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
fyLabel,
|
||||
FY_MONTHS,
|
||||
WEEK_LABELS,
|
||||
SCOPE_LABELS,
|
||||
type CostCentreSpend,
|
||||
} from "@/lib/reports";
|
||||
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
|
||||
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
|
||||
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||
import { Kpi, KpiStrip } from "@/components/reports/kpi";
|
||||
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
|
||||
|
||||
export const metadata: Metadata = { title: "Cost Centres — Reports" };
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
|
||||
export default async function CostCentresReport({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; sel?: string; cmp?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return null;
|
||||
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
|
||||
|
||||
const sp = await searchParams;
|
||||
const ds = await getReportDataset();
|
||||
const gran = parseGranularity(sp.gran);
|
||||
const scope = parseScope(sp.scope);
|
||||
const fy = resolveFy(ds, sp.fy);
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
const month = resolveMonth(ds, fy, sp.month);
|
||||
const sel = parseSel(sp.sel);
|
||||
const cmp = sp.cmp === "1" && sel.length > 0;
|
||||
|
||||
const ranked = costCentreRows(ds, fy);
|
||||
const rankOf = (r: CostCentreSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
|
||||
ranked.sort((a, b) => rankOf(b) - rankOf(a));
|
||||
const shown = cmp ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
|
||||
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
|
||||
const top = shown[0];
|
||||
const sparkOf = (r: CostCentreSpend) => (yearly ? r.fyTotals : weekly ? costCentreWeekly(ds, r.id, fy, month) : r.months);
|
||||
|
||||
const nf = ds.fys.length;
|
||||
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
|
||||
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
|
||||
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
|
||||
|
||||
// Chart data — one distinct colour per item (series) in every granularity; the
|
||||
// x-axis is months / weeks / financial years. (Yearly is grouped bars per item,
|
||||
// not per FY, so each cost centre keeps its own colour.)
|
||||
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
|
||||
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
|
||||
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
|
||||
const row: Record<string, string | number> = { x: lab };
|
||||
shown.forEach((r) => (row[r.name] = sparkOf(r)[i]));
|
||||
return row;
|
||||
});
|
||||
const series: Series[] = shown.map((r, i) => ({ key: r.name, color: colored(i) }));
|
||||
|
||||
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
|
||||
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
|
||||
|
||||
// Query-string helpers (preserve current filters).
|
||||
const baseParams: Record<string, string | undefined> = {
|
||||
fy: String(fy),
|
||||
gran: gran === "monthly" ? undefined : gran,
|
||||
scope: scope === "top5" ? undefined : scope,
|
||||
month: weekly ? String(month) : undefined,
|
||||
};
|
||||
const qs = (extra: Record<string, string | undefined>) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries({ ...baseParams, ...extra })) if (v) p.set(k, v);
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "";
|
||||
};
|
||||
const selHref = (id: string) => {
|
||||
const next = toggleSel(sel, id);
|
||||
return `/reports/cost-centres${qs({ sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
|
||||
};
|
||||
const detailHref = (id: string) => `/reports/cost-centres/${id}${qs({ scope: undefined })}`;
|
||||
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}${cmp ? `&sel=${sel.join(",")}` : ""}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
|
||||
<ReportsToolbar
|
||||
fys={ds.fys}
|
||||
fy={fy}
|
||||
gran={gran}
|
||||
scope={cmp ? undefined : scope}
|
||||
month={month}
|
||||
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
|
||||
exportHref={exportHref}
|
||||
/>
|
||||
|
||||
{cmp ? (
|
||||
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
← Back to browse
|
||||
</Link>
|
||||
) : (
|
||||
sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ sel: undefined, cmp: undefined })} />
|
||||
)}
|
||||
|
||||
<ReportTitle
|
||||
title={cmp ? "Custom comparison" : "Cost Centres"}
|
||||
subtitle={
|
||||
cmp
|
||||
? `Comparing ${shown.length} selected cost centres. Untick a row to remove it.`
|
||||
: "Approved spend compared across cost centres (vessels). Tick rows to graph together, or click a row for its report."
|
||||
}
|
||||
/>
|
||||
|
||||
{grand === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
|
||||
No approved spend recorded for {periodLabel} yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<KpiStrip>
|
||||
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
|
||||
<Kpi label="Cost centres" value={String(shown.length)} sub={cmp ? "selected" : `${SCOPE_LABELS[scope]} shown`} />
|
||||
<Kpi label="Highest spender" value={top?.name ?? "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
|
||||
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
|
||||
</KpiStrip>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-neutral-900">
|
||||
{yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "Monthly spend by cost centre"}
|
||||
</p>
|
||||
<span className="text-xs text-neutral-400">{periodLabel}</span>
|
||||
</div>
|
||||
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr>
|
||||
<th className="px-5 py-3">Cost Centre</th>
|
||||
<th className="px-5 py-3">Trend</th>
|
||||
<th className="px-5 py-3 text-right">Total Spend</th>
|
||||
<th className="px-5 py-3 text-right">% of Shown</th>
|
||||
<th className="px-5 py-3 text-right">POs</th>
|
||||
<th className="px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{shown.map((r) => {
|
||||
const value = rankOf(r);
|
||||
const pct = grand ? (value / grand) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.id} className="group hover:bg-primary-50/40">
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<SelectCheckbox checked={sel.includes(r.id)} href={selHref(r.id)} />
|
||||
<Link href={detailHref(r.id)} className="block font-medium text-neutral-900 group-hover:text-primary-700">
|
||||
{r.name}
|
||||
<span className="ml-2 text-xs font-normal text-neutral-400">{r.code}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Sparkline values={sparkOf(r)} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100">
|
||||
<div className="h-full rounded-full bg-primary-600" style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
<span className="w-10 text-right tabular-nums text-neutral-500">{pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums text-neutral-500">{r.poCount}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Link href={detailHref(r.id)}>
|
||||
<ChevronRight className="inline h-4 w-4 text-neutral-300 group-hover:text-primary-500" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { auth } from "@/auth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getReportDataset,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
accountLevelRows,
|
||||
topAccountsForCostCentre,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
accountNodeSpend,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
parseSel,
|
||||
resolveFy,
|
||||
fyLabel,
|
||||
type Tier,
|
||||
} from "@/lib/reports";
|
||||
|
||||
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
||||
const cell = (v: string | number) => {
|
||||
const s = String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
function csv(headers: string[], rows: (string | number)[][]): string {
|
||||
return [headers, ...rows].map((r) => r.map(cell).join(",")).join("\n");
|
||||
}
|
||||
function file(name: string, body: string) {
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${name}-${Date.now()}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// CSV export for the Reports → Purchasing views. The `dim` query param mirrors
|
||||
// the page the user is on, so the download matches what's on screen.
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!hasPermission(session.user.role, "view_analytics")) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const dim = sp.get("dim") ?? "cost-centre";
|
||||
const ds = await getReportDataset();
|
||||
const idx = buildAccountIndex(ds.accounts);
|
||||
const gran = parseGranularity(sp.get("gran") ?? undefined);
|
||||
const scope = parseScope(sp.get("scope") ?? undefined);
|
||||
const fy = resolveFy(ds, sp.get("fy") ?? undefined);
|
||||
const yearly = gran === "yearly";
|
||||
const fyCols = ds.fys.map(fyLabel);
|
||||
|
||||
const sel = parseSel(sp.get("sel") ?? undefined);
|
||||
|
||||
if (dim === "cost-centre") {
|
||||
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
||||
const picked = sel.length ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
|
||||
const rows = picked.map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
|
||||
return file("pelagia-cost-centre-spend", csv(["Code", "Cost Centre", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
|
||||
}
|
||||
|
||||
if (dim === "accounting-code") {
|
||||
let ranked;
|
||||
if (sel.length) {
|
||||
ranked = sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }));
|
||||
} else {
|
||||
const parent = sp.get("parent");
|
||||
const parentId = parent && idx.byId.has(parent) ? parent : null;
|
||||
ranked = accountLevelRows(ds, idx, parentId, fy);
|
||||
}
|
||||
ranked.sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
|
||||
const picked = sel.length ? ranked : applyScope(ranked, scope);
|
||||
const rows = picked.map((r) => [r.node.code, r.node.name, r.node.tier, ...r.fyTotals, r.total, r.poCount]);
|
||||
return file("pelagia-accounting-code-spend", csv(["Code", "Name", "Tier", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
|
||||
}
|
||||
|
||||
if (dim === "cost-centre-detail") {
|
||||
const id = sp.get("id") ?? "";
|
||||
const tier = (["Heading", "Sub-heading", "Leaf"] as Tier[]).includes(sp.get("tier") as Tier) ? (sp.get("tier") as Tier) : "Leaf";
|
||||
const rows = topAccountsForCostCentre(ds, idx, id, fy, tier).map((b) => [b.label, b.value]);
|
||||
return file("pelagia-cost-centre-detail", csv([tier, `Spend (${fyLabel(fy)})`], rows));
|
||||
}
|
||||
|
||||
if (dim === "accounting-code-detail") {
|
||||
const id = sp.get("id") ?? "";
|
||||
const leaf = idx.isLeaf(id);
|
||||
const mode = leaf || sp.get("break") === "cc" ? "cc" : "children";
|
||||
const bd = mode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy);
|
||||
const rows = bd.map((b) => [b.label, b.value]);
|
||||
return file("pelagia-accounting-code-detail", csv([mode === "cc" ? "Cost centre" : "Sub-account", `Spend (${fyLabel(fy)})`], rows));
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown report dimension" }, { status: 400 });
|
||||
}
|
||||
|
|
@ -56,50 +56,36 @@ const HISTORY_ROLES: Role[] = [
|
|||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
||||
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
||||
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
|
||||
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
|
||||
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
||||
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
||||
{ href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
|
||||
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||
];
|
||||
|
||||
// ── Purchasing section ────────────────────────────────────────────────────────
|
||||
// Purchase Order actions (create / browse / import / history)
|
||||
const PURCHASING_PO: NavItem[] = [
|
||||
{ href: "/po/new", label: "New Purchase Order", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
||||
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
||||
{ href: "/po/import", label: "Import Purchase Order", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
|
||||
{ href: "/history", label: "Purchase Order History", icon: History, roles: HISTORY_ROLES },
|
||||
];
|
||||
|
||||
// Staff browsing items (product catalogue + cart for PO creation)
|
||||
const PURCHASING_STAFF: NavItem[] = [
|
||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||
];
|
||||
|
||||
// Manager catalogue management — Sites conditionally shown
|
||||
// Admin does not use Purchasing; their links live under Administration
|
||||
const PURCHASING_MGMT: NavItem[] = [
|
||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
||||
...(INVENTORY_ENABLED
|
||||
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||
|
||||
// ── Reports section ───────────────────────────────────────────────────────────
|
||||
// Spend analytics, gated by `view_analytics` (Manager / SuperUser / Auditor /
|
||||
// Admin). Links are grouped under a "Purchasing" subheading so other domains
|
||||
// (e.g. Crewing) can hang their own report groups here later.
|
||||
const REPORTS_ROLES: Role[] = ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
|
||||
const REPORTS_PURCHASING: NavItem[] = [
|
||||
{ href: "/reports/cost-centres", label: "Cost Centres", icon: Ship, roles: REPORTS_ROLES },
|
||||
{ href: "/reports/accounting-codes", label: "Accounting Codes", icon: Building2, roles: REPORTS_ROLES },
|
||||
];
|
||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||
|
||||
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
||||
|
|
@ -144,14 +130,10 @@ const ADMIN_ITEMS: NavItem[] = [
|
|||
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
||||
];
|
||||
|
||||
interface NavGroup {
|
||||
label?: string; // optional subheading shown above the group's links
|
||||
items: NavItem[];
|
||||
}
|
||||
interface Section {
|
||||
id: string;
|
||||
label: string;
|
||||
groups: NavGroup[];
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
function isItemActive(href: string, pathname: string) {
|
||||
|
|
@ -162,29 +144,22 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
const pathname = usePathname();
|
||||
const isAdmin = userRole === "ADMIN";
|
||||
|
||||
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole);
|
||||
const visibleMain = NAV_ITEMS.filter(visible);
|
||||
const visiblePurchasing = PURCHASING_ITEMS.filter(visible);
|
||||
const visibleReports = REPORTS_PURCHASING.filter(visible);
|
||||
const visibleCrewing = CREWING_ITEMS.filter(visible);
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter(visible);
|
||||
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
|
||||
|
||||
// Headed, collapsible sections (the main links above sit outside any section).
|
||||
// A section holds one or more groups; a group can carry an optional subheading.
|
||||
const sections: Section[] = [
|
||||
{ id: "purchasing", label: "Purchasing", groups: [{ items: visiblePurchasing }] },
|
||||
{ id: "reports", label: "Reports", groups: [{ label: "Purchasing", items: visibleReports }] },
|
||||
{ id: "crewing", label: "Crewing", groups: [{ items: visibleCrewing }] },
|
||||
{ id: "administration", label: "Administration", groups: [{ items: adminItems }] },
|
||||
]
|
||||
.map((s) => ({ ...s, groups: s.groups.filter((g) => g.items.length > 0) }))
|
||||
.filter((s) => s.groups.length > 0);
|
||||
{ id: "purchasing", label: "Purchasing", items: visiblePurchasing },
|
||||
{ id: "crewing", label: "Crewing", items: visibleCrewing },
|
||||
{ id: "administration", label: "Administration", items: adminItems },
|
||||
].filter((s) => s.items.length > 0);
|
||||
|
||||
const sectionItems = (s: Section) => s.groups.flatMap((g) => g.items);
|
||||
// The section (if any) that holds the currently active route.
|
||||
const activeSectionId =
|
||||
sections.find((s) => sectionItems(s).some((i) => isItemActive(i.href, pathname)))?.id ?? null;
|
||||
sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null;
|
||||
|
||||
// Single-open accordion, collapsed by default. Auto-expand the section that
|
||||
// contains the active route so the user is never stranded on a hidden link.
|
||||
|
|
@ -226,17 +201,8 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
/>
|
||||
{isOpen && (
|
||||
<div id={regionId} className="space-y-0.5">
|
||||
{section.groups.map((group, gi) => (
|
||||
<div key={group.label ?? gi} className="space-y-0.5">
|
||||
{group.label && (
|
||||
<p className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-300">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</div>
|
||||
{section.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
|
||||
interface Props {
|
||||
/** Arm the guard — true once the form has unsaved changes. */
|
||||
enabled: boolean;
|
||||
/** Persist the in-progress PO as a draft. Should navigate away on success. */
|
||||
onSaveDraft: () => void;
|
||||
/** True while the draft save is in flight (drives the button label/disable). */
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
// Warns the user before they leave a PO form with unsaved changes (issue #18).
|
||||
// Two paths are covered:
|
||||
// • Hard navigations (refresh, tab close, external links) → the browser's own
|
||||
// "Leave site?" prompt (browsers can't render custom buttons here, so the
|
||||
// save-as-draft option isn't offered on this path).
|
||||
// • In-app navigations (sidebar / header / any internal <a>) → intercepted and
|
||||
// replaced with our own modal offering Save as draft / Discard / Stay.
|
||||
export function UnsavedChangesGuard({ enabled, onSaveDraft, saving }: Props) {
|
||||
const router = useRouter();
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
// Listeners are attached once; read `enabled` through a ref so they always see
|
||||
// the latest value without re-binding on every keystroke.
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (!enabledRef.current) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!enabledRef.current || e.defaultPrevented) return;
|
||||
// Ignore non-primary clicks and modifier-clicks (new tab / download etc.).
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const anchor = (e.target as HTMLElement | null)?.closest("a");
|
||||
const href = anchor?.getAttribute("href");
|
||||
if (!anchor || !href || href.startsWith("#")) return;
|
||||
if (anchor.hasAttribute("download")) return;
|
||||
if (anchor.target && anchor.target !== "_self") return;
|
||||
|
||||
const url = new URL(href, window.location.href);
|
||||
// External origin → let the browser's beforeunload prompt handle it.
|
||||
if (url.origin !== window.location.origin) return;
|
||||
// Same page (e.g. a no-op link) → nothing to guard.
|
||||
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingHref(url.pathname + url.search + url.hash);
|
||||
}
|
||||
// Capture phase so we run before Next's <Link> click handler.
|
||||
document.addEventListener("click", onClick, true);
|
||||
return () => document.removeEventListener("click", onClick, true);
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
const href = pendingHref;
|
||||
setPendingHref(null);
|
||||
enabledRef.current = false; // let this navigation through
|
||||
if (href) router.push(href);
|
||||
}, [pendingHref, router]);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
if (saving) return;
|
||||
setPendingHref(null);
|
||||
}, [saving]);
|
||||
|
||||
function saveDraft() {
|
||||
// Close the prompt so any inline save error is visible; the save action
|
||||
// navigates to the PO on success.
|
||||
setPendingHref(null);
|
||||
onSaveDraft();
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminDialog title="Unsaved changes" open={pendingHref !== null} onClose={stay}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-neutral-600">
|
||||
You have unsaved changes on this purchase order. Save it as a draft before leaving, or discard your changes?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={stay}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 sm:order-1"
|
||||
>
|
||||
Stay on page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={discard}
|
||||
disabled={saving}
|
||||
className="rounded-lg border border-danger-200 bg-white px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60 sm:order-2"
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveDraft}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors sm:order-3"
|
||||
>
|
||||
{saving ? "Saving…" : "Save as draft"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { SERIES_COLORS } from "@/lib/report-colors";
|
||||
|
||||
// Re-exported for back-compat; new server-component code should import the
|
||||
// palette from "@/lib/report-colors" directly (see that file for why).
|
||||
export { SERIES_COLORS };
|
||||
|
||||
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
|
||||
export function formatINRShort(n: number): string {
|
||||
const a = Math.abs(n);
|
||||
if (a >= 1_00_00_000) return `₹${(n / 1_00_00_000).toFixed(1)}Cr`;
|
||||
if (a >= 1_00_000) return `₹${(n / 1_00_000).toFixed(1)}L`;
|
||||
if (a >= 1_000) return `₹${(n / 1_000).toFixed(0)}K`;
|
||||
return `₹${n.toFixed(0)}`;
|
||||
}
|
||||
function fullINR(n: number): string {
|
||||
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
key: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ComparisonProps {
|
||||
kind: "lines" | "bars";
|
||||
data: Record<string, string | number>[];
|
||||
xKey: string;
|
||||
series: Series[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Multi-series comparison: monthly trend lines, or year-over-year grouped bars. */
|
||||
export function ComparisonChart({ kind, data, xKey, series, height = 340 }: ComparisonProps) {
|
||||
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{kind === "lines" ? (
|
||||
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} iconType="plainline" />
|
||||
{series.map((s) => (
|
||||
<Line key={s.key} type="monotone" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 5 }} />
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey={xKey} {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{series.map((s) => (
|
||||
<Bar key={s.key} dataKey={s.key} fill={s.color} radius={[3, 3, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrendProps {
|
||||
kind: "line" | "bar";
|
||||
data: { label: string; value: number }[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Single-series spend trend (monthly line or yearly bar). */
|
||||
export function TrendChart({ kind, data, height = 300 }: TrendProps) {
|
||||
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{kind === "line" ? (
|
||||
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="label" {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} />
|
||||
<Line type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} fill="rgba(37,99,235,0.08)" />
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
|
||||
<XAxis dataKey="label" {...axis} />
|
||||
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Horizontal top-N breakdown bars (each bar its own colour). */
|
||||
export function BreakdownChart({ data, height = 300 }: { data: { label: string; value: number }[]; height?: number }) {
|
||||
const trimmed = data.map((d) => ({ ...d, short: d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart layout="vertical" data={trimmed} margin={{ top: 4, right: 16, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
|
||||
<XAxis type="number" tickFormatter={formatINRShort} tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
|
||||
<YAxis type="category" dataKey="short" width={140} tick={{ fontSize: 11, fill: "#525252" }} tickLine={false} axisLine={false} />
|
||||
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{trimmed.map((_, i) => (
|
||||
<Cell key={i} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiny inline trend sparkline (plain SVG — no chart library needed per row). */
|
||||
export function Sparkline({ values, width = 90, height = 28 }: { values: number[]; width?: number; height?: number }) {
|
||||
if (values.length < 2) return <svg width={width} height={height} />;
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const pad = 3;
|
||||
const span = max - min || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = pad + (i / (values.length - 1)) * (width - 2 * pad);
|
||||
const y = height - pad - ((v - min) / span) * (height - 2 * pad);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const last = pts[pts.length - 1].split(",");
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline points={pts.join(" ")} fill="none" stroke="#2563eb" strokeWidth={1.5} />
|
||||
<circle cx={last[0]} cy={last[1]} r={2} fill="#2563eb" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Presentational KPI tile (server component — no interactivity). `delta` colours
|
||||
// the sub-line green/red for positive/negative changes (e.g. YoY).
|
||||
export function Kpi({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
delta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
delta?: number;
|
||||
}) {
|
||||
const subColor = delta === undefined ? "text-neutral-400" : delta >= 0 ? "text-green-600" : "text-red-600";
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">{label}</p>
|
||||
<p className="mt-1.5 text-xl font-semibold text-neutral-900">{value}</p>
|
||||
<p className={cn("mt-0.5 text-xs", subColor)}>{sub ?? " "}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiStrip({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">{children}</div>;
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { ChevronRight, Check } from "lucide-react";
|
||||
|
||||
// Reports breadcrumb: always rooted at "Reports", then the section and any
|
||||
// drill/detail crumbs. A crumb with an href is a link; the last is the current.
|
||||
export function ReportBreadcrumb({ trail }: { trail: { label: string; href?: string }[] }) {
|
||||
return (
|
||||
<nav className="mb-4 flex flex-wrap items-center gap-2 text-sm text-neutral-500">
|
||||
<span>Reports</span>
|
||||
{trail.map((t, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
{t.href ? (
|
||||
<Link href={t.href} className="hover:text-neutral-800">{t.label}</Link>
|
||||
) : (
|
||||
<span className="font-medium text-neutral-900">{t.label}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Server-rendered segmented control: each option is a link that re-renders the
|
||||
// page with the new value in the query string (used for tier / break-down / top-N).
|
||||
export function SegLink({
|
||||
label,
|
||||
options,
|
||||
current,
|
||||
hrefFor,
|
||||
}: {
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
current: string;
|
||||
hrefFor: (v: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400">{label}</span>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.value}
|
||||
href={hrefFor(o.value)}
|
||||
className={
|
||||
"rounded-md px-2.5 py-1 font-medium " +
|
||||
(o.value === current ? "bg-primary-600 text-white" : "text-neutral-500 hover:text-neutral-800")
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A checkbox rendered as a navigation link — toggles this row's id in the
|
||||
// `?sel=` custom-comparison selection (keeps the report fully server-rendered).
|
||||
export function SelectCheckbox({ checked, href, title }: { checked: boolean; href: string; title?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title ?? "Select to graph"}
|
||||
scroll={false}
|
||||
className={
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border " +
|
||||
(checked ? "border-primary-600 bg-primary-600 text-white" : "border-neutral-300 bg-white hover:border-primary-500")
|
||||
}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Sticky banner shown while rows are selected: jump to the custom comparison or clear.
|
||||
export function CompareBar({ count, compareHref, clearHref }: { count: number; compareHref: string; clearHref: string }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5">
|
||||
<span className="text-sm font-medium text-primary-800">{count} selected</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={compareHref} className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
Compare selected
|
||||
</Link>
|
||||
<Link href={clearHref} className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
|
||||
Clear
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{subtitle && <p className="mt-1 text-sm text-neutral-500">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fyLabel, SCOPE_LABELS, type Granularity, type ScopeMode } from "@/lib/reports";
|
||||
|
||||
interface Props {
|
||||
fys: number[];
|
||||
fy: number;
|
||||
gran: Granularity;
|
||||
/** Pass a scope to render the Top/Bottom-N "Show" control (index pages only). */
|
||||
scope?: ScopeMode;
|
||||
/** Weekly mode: the selected FY-month index + the 12 month options. */
|
||||
month?: number;
|
||||
monthOptions?: { value: number; label: string }[];
|
||||
exportHref: string;
|
||||
}
|
||||
|
||||
const GRANS: Granularity[] = ["weekly", "monthly", "yearly"];
|
||||
|
||||
// Pinned filter toolbar shared by the report pages. Each control writes its value
|
||||
// into the URL query string (preserving the rest) so the server component
|
||||
// re-renders the report for the new filters — no client-side data fetching.
|
||||
export function ReportsToolbar({ fys, fy, gran, scope, month, monthOptions, exportHref }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
|
||||
function update(patch: Record<string, string | null>) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v === null || v === "") q.delete(k);
|
||||
else q.set(k, v);
|
||||
}
|
||||
const qs = q.toString();
|
||||
router.push(qs ? `${pathname}?${qs}` : pathname);
|
||||
}
|
||||
|
||||
const yearly = gran === "yearly";
|
||||
const weekly = gran === "weekly";
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-4 mb-6 border-b border-neutral-200 bg-neutral-50/95 px-4 py-3 backdrop-blur md:-mx-6 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
|
||||
{GRANS.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => update({ gran: g === "monthly" ? null : g })}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 font-medium capitalize transition-colors",
|
||||
gran === g ? "bg-primary-600 text-white shadow-sm" : "text-neutral-500 hover:text-neutral-800"
|
||||
)}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!yearly && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
|
||||
<select
|
||||
value={fy}
|
||||
onChange={(e) => update({ fy: e.target.value })}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
|
||||
>
|
||||
{[...fys].reverse().map((y) => (
|
||||
<option key={y} value={y}>{fyLabel(y)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{weekly && monthOptions && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => update({ month: e.target.value })}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
|
||||
>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{scope && (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(e) => update({ scope: e.target.value === "top5" ? null : e.target.value })}
|
||||
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
|
||||
>
|
||||
{(Object.keys(SCOPE_LABELS) as ScopeMode[]).map((s) => (
|
||||
<option key={s} value={s}>{SCOPE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={exportHref}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
|
||||
export type VendorOption = { id: string; name: string; vendorId: string | null };
|
||||
|
||||
/**
|
||||
* Filter vendors by a free-text query, matching case-insensitively against the
|
||||
* vendor name and the formal code (`vendorId`). An empty/whitespace query
|
||||
* returns the full list unchanged.
|
||||
*/
|
||||
export function filterVendors<T extends VendorOption>(vendors: T[], query: string): T[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return vendors;
|
||||
return vendors.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
(v.vendorId ? v.vendorId.toLowerCase().includes(q) : false)
|
||||
);
|
||||
}
|
||||
|
||||
/** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */
|
||||
export function vendorLabel(v: VendorOption): string {
|
||||
return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
vendors: VendorOption[];
|
||||
/** Initial selected vendor id (uncontrolled — the component owns its state). */
|
||||
initialValue?: string;
|
||||
placeholder?: string;
|
||||
/** Optional callback when the selection changes. */
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function VendorSelect({
|
||||
name,
|
||||
vendors,
|
||||
initialValue = "",
|
||||
placeholder = "No vendor selected",
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
const updatePortalPos = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setPortalStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 9999,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePortalPos();
|
||||
}, [open, updatePortalPos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener("scroll", updatePortalPos, true);
|
||||
window.addEventListener("resize", updatePortalPos);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updatePortalPos, true);
|
||||
window.removeEventListener("resize", updatePortalPos);
|
||||
};
|
||||
}, [open, updatePortalPos]);
|
||||
|
||||
// Close on outside click / Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") { setOpen(false); setQuery(""); }
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) searchRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const selected = vendors.find((v) => v.id === value);
|
||||
const selectedLabel = selected ? vendorLabel(selected) : "";
|
||||
|
||||
const filtered = filterVendors(vendors, query);
|
||||
|
||||
const select = useCallback((id: string) => {
|
||||
setValue(id);
|
||||
onChange?.(id);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}, [onChange]);
|
||||
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setValue("");
|
||||
onChange?.("");
|
||||
}, [onChange]);
|
||||
|
||||
const dropdownPanel = (
|
||||
<div
|
||||
style={portalStyle}
|
||||
className="rounded-lg border border-neutral-200 bg-white shadow-xl"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
|
||||
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name or code…"
|
||||
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
|
||||
{/* "No vendor selected" empty option — always available */}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(""); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
||||
${value === "" ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-500"}`}
|
||||
>
|
||||
No vendor selected
|
||||
</button>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-5 text-sm text-center text-neutral-400">No vendors match “{query}”</p>
|
||||
) : (
|
||||
filtered.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(v.id); }}
|
||||
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
|
||||
${value === v.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
|
||||
>
|
||||
<span className="flex-1 leading-snug">{v.name}</span>
|
||||
<span className="font-mono text-xs text-neutral-400 shrink-0">
|
||||
{v.vendorId ?? "unverified"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<input type="hidden" name={name} value={value} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 rounded-lg border
|
||||
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
|
||||
bg-white text-left transition-colors px-3 py-2.5 text-sm
|
||||
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20`}
|
||||
>
|
||||
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
{value && (
|
||||
<span role="button" tabIndex={0} onClick={handleClear}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
|
||||
className="text-neutral-300 hover:text-neutral-500 transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`text-neutral-400 transition-transform h-4 w-4 ${open ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && mounted && createPortal(dropdownPanel, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// Shared, dependency-free pagination math used by list pages (e.g. PO History).
|
||||
// Keeps page-size validation and out-of-range page clamping in one testable place.
|
||||
|
||||
export interface PaginationInput {
|
||||
/** Raw `perPage` value from the query string (may be undefined / invalid). */
|
||||
perPageParam?: string | number;
|
||||
/** Raw `page` value from the query string (may be undefined / invalid). */
|
||||
pageParam?: string | number;
|
||||
/** Total number of rows matching the current filter. */
|
||||
total: number;
|
||||
/** Allowed page sizes; anything else falls back to `defaultPerPage`. */
|
||||
options: number[];
|
||||
defaultPerPage: number;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
/** Rows to skip for the current page (Prisma `skip`). */
|
||||
skip: number;
|
||||
/** Rows to take for the current page (Prisma `take`). */
|
||||
take: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a safe page size and (1-based) page number from untrusted query
|
||||
* params. `perPage` is clamped to the allowed `options`; `page` is clamped to
|
||||
* `[1, totalPages]` so an out-of-range or non-numeric page never paginates past
|
||||
* the last page.
|
||||
*/
|
||||
export function resolvePagination({
|
||||
perPageParam,
|
||||
pageParam,
|
||||
total,
|
||||
options,
|
||||
defaultPerPage,
|
||||
}: PaginationInput): Pagination {
|
||||
const perPage = options.includes(Number(perPageParam)) ? Number(perPageParam) : defaultPerPage;
|
||||
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||
const requested = Number(pageParam);
|
||||
const page = Math.min(
|
||||
Math.max(1, Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 1),
|
||||
totalPages,
|
||||
);
|
||||
return { perPage, page, totalPages, skip: (page - 1) * perPage, take: perPage };
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// Service-token auth for the PO export route, shared by the auth middleware and
|
||||
// (conceptually) the export route handler.
|
||||
//
|
||||
// PdfService ("Email PO to vendor", issue #14) fetches `/api/po/<id>/export`
|
||||
// WITHOUT a user session, authenticating with a `svc` query param that must equal
|
||||
// PDF_SERVICE_TOKEN. The route handler validates that token, but the auth
|
||||
// middleware runs first and would otherwise redirect the unauthenticated request
|
||||
// to /login — so the middleware uses this to let exactly that one route through
|
||||
// when the token matches.
|
||||
//
|
||||
// Kept dependency-free so it's safe to import into the Edge middleware and easy to
|
||||
// unit-test. `token` is `process.env.PDF_SERVICE_TOKEN` (undefined when the PDF
|
||||
// service isn't configured → always denied).
|
||||
const EXPORT_PATH = /^\/api\/po\/[^/]+\/export\/?$/;
|
||||
|
||||
export function isPdfExportServiceRequest(
|
||||
pathname: string,
|
||||
svc: string | null | undefined,
|
||||
token: string | undefined
|
||||
): boolean {
|
||||
if (!token || !svc) return false;
|
||||
if (svc !== token) return false;
|
||||
return EXPORT_PATH.test(pathname);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { db } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Product catalogue sync — registers a PO's line items as reusable `Product`s
|
||||
* (the `/catalogue/items` catalogue) and keeps last/per-vendor prices fresh:
|
||||
* - line items with no `productId` are matched to an existing product by name,
|
||||
* or a brand-new product is created, and the line item is linked back;
|
||||
* - `lastPrice`/`lastVendorId` and the per-vendor price are upserted.
|
||||
*
|
||||
* Called at **approval** (so approved items are immediately reusable in further
|
||||
* POs) and again at **payment** (to refresh prices on the final figures). The
|
||||
* function is idempotent — re-running matches the same product by name/id.
|
||||
*/
|
||||
function nameToCode(name: string): string {
|
||||
const slug = name.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.substring(0, 20);
|
||||
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
|
||||
}
|
||||
|
||||
export async function syncProductCatalog(
|
||||
poId: string,
|
||||
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
|
||||
vendorId: string | null,
|
||||
actorId: string
|
||||
) {
|
||||
const updatedProductIds: string[] = [];
|
||||
|
||||
for (const li of lineItems) {
|
||||
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
|
||||
let productId = li.productId;
|
||||
let priceChanged = false;
|
||||
|
||||
if (!productId) {
|
||||
// Try to find an existing product by name (case-insensitive)
|
||||
const existing = await db.product.findFirst({
|
||||
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
|
||||
select: { id: true, lastPrice: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
productId = existing.id;
|
||||
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
|
||||
} else {
|
||||
// Create a new product — first-time registration, not a price update
|
||||
const code = nameToCode(li.name);
|
||||
try {
|
||||
const created = await db.product.create({
|
||||
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
|
||||
});
|
||||
productId = created.id;
|
||||
} catch {
|
||||
// Code collision (extremely unlikely) — add extra entropy
|
||||
const created = await db.product.create({
|
||||
data: {
|
||||
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
|
||||
name: li.name,
|
||||
lastPrice: unitPrice,
|
||||
lastVendorId: vendorId,
|
||||
},
|
||||
});
|
||||
productId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Link the line item to the product for future reference
|
||||
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
|
||||
} else {
|
||||
const current = await db.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: { lastPrice: true },
|
||||
});
|
||||
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
|
||||
}
|
||||
|
||||
// Always update lastPrice / lastVendorId on the product
|
||||
await db.product.update({
|
||||
where: { id: productId },
|
||||
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
|
||||
});
|
||||
|
||||
// Upsert per-vendor price if PO has a vendor
|
||||
if (vendorId) {
|
||||
await db.productVendorPrice.upsert({
|
||||
where: { productId_vendorId: { productId, vendorId } },
|
||||
update: { price: unitPrice },
|
||||
create: { productId, vendorId, price: unitPrice },
|
||||
});
|
||||
}
|
||||
|
||||
if (priceChanged) updatedProductIds.push(productId);
|
||||
}
|
||||
|
||||
if (updatedProductIds.length > 0) {
|
||||
await db.pOAction.create({
|
||||
data: {
|
||||
actionType: "PRODUCT_PRICE_UPDATED",
|
||||
actorId,
|
||||
poId,
|
||||
metadata: { updatedProductIds },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// Shared categorical palette for the Reports charts + table swatches.
|
||||
//
|
||||
// This is a plain, dependency-free module (NO "use client", no server-only
|
||||
// imports) so it can be imported by BOTH the server-component report pages and
|
||||
// the client chart components and resolve to the real array in each. It must NOT
|
||||
// live in a "use client" module: a plain value imported from a client module
|
||||
// into a server component becomes a client-reference proxy (not the array), so
|
||||
// `SERIES_COLORS[i]` would silently be `undefined` and every series would fall
|
||||
// back to recharts' default colour.
|
||||
export const SERIES_COLORS = [
|
||||
"#2563eb",
|
||||
"#16a34a",
|
||||
"#9333ea",
|
||||
"#ea580c",
|
||||
"#0891b2",
|
||||
"#dc2626",
|
||||
"#ca8a04",
|
||||
"#4f46e5",
|
||||
"#0d9488",
|
||||
"#db2777",
|
||||
];
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
import { db } from "@/lib/db";
|
||||
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Spend reporting (Reports → Purchasing). Aggregates approved purchase-order
|
||||
* spend across two dimensions:
|
||||
* • Cost centres — the PO's vessel (`PurchaseOrder.vesselId`).
|
||||
* • Accounting codes — the self-referential `Account` tree (Heading →
|
||||
* Sub-heading → Leaf); each PO's `accountId` is a leaf, rolled up to parents.
|
||||
*
|
||||
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
|
||||
* dated by `approvedAt` and valued at the full `totalAmount` — the same
|
||||
* definition the dashboard's spend tiles use. Financial year is the Indian
|
||||
* Apr–Mar year. The heavy lifting is a single query in `getReportDataset()`;
|
||||
* everything below is pure functions over that dataset so they're unit-testable.
|
||||
*/
|
||||
|
||||
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
|
||||
|
||||
/** Indian FY start year for a date (Apr–Mar): Jan–Mar belong to the prior year. */
|
||||
export function fyStartYear(d: Date): number {
|
||||
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
|
||||
}
|
||||
/** "FY 2025–26" for start year 2025. */
|
||||
export function fyLabel(start: number): string {
|
||||
return `FY ${start}–${String((start + 1) % 100).padStart(2, "0")}`;
|
||||
}
|
||||
/** Month index within the FY: Apr=0 … Mar=11. */
|
||||
export function fyMonthIndex(d: Date): number {
|
||||
return (d.getMonth() - 3 + 12) % 12;
|
||||
}
|
||||
/** Week-of-month bucket: 0–4 (W1–W5) from the day of the month. */
|
||||
export function weekOfMonth(d: Date): number {
|
||||
return Math.min(4, Math.floor((d.getDate() - 1) / 7));
|
||||
}
|
||||
export const WEEK_LABELS = ["W1", "W2", "W3", "W4", "W5"] as const;
|
||||
|
||||
export type Tier = "Heading" | "Sub-heading" | "Leaf";
|
||||
|
||||
export interface CostCentre {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
export interface AccountNode {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
tier: Tier;
|
||||
}
|
||||
/** One row per (PO, accounting code). Multi-account POs yield several rows. */
|
||||
export interface SpendRow {
|
||||
poId: string;
|
||||
vesselId: string;
|
||||
accountId: string;
|
||||
amount: number;
|
||||
fy: number;
|
||||
month: number; // 0–11 within the FY (Apr=0)
|
||||
week: number; // 0–4 within the calendar month
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a PO's spend across the accounting codes its line items carry, so the
|
||||
* accounting-code report attributes multi-account POs correctly. The PO's
|
||||
* `totalAmount` is allocated **proportionally** to each line's account share
|
||||
* (line `accountId`, falling back to the PO-level account), so the per-PO rows
|
||||
* always sum back to `totalAmount` exactly. With no line items (or zero line
|
||||
* value) the whole amount lands on the PO-level account.
|
||||
*/
|
||||
export function allocatePoSpend(
|
||||
po: { id: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; week: number },
|
||||
lines: { accountId: string | null; amount: number }[]
|
||||
): SpendRow[] {
|
||||
const base = { poId: po.id, vesselId: po.vesselId, fy: po.fy, month: po.month, week: po.week };
|
||||
const byAccount = new Map<string, number>();
|
||||
let lineTotal = 0;
|
||||
for (const l of lines) {
|
||||
const key = l.accountId ?? po.accountId;
|
||||
byAccount.set(key, (byAccount.get(key) ?? 0) + l.amount);
|
||||
lineTotal += l.amount;
|
||||
}
|
||||
if (byAccount.size === 0 || lineTotal <= 0) {
|
||||
return [{ ...base, accountId: po.accountId, amount: po.amount }];
|
||||
}
|
||||
return [...byAccount.entries()].map(([accountId, share]) => ({ ...base, accountId, amount: po.amount * (share / lineTotal) }));
|
||||
}
|
||||
|
||||
export interface ReportDataset {
|
||||
rows: SpendRow[];
|
||||
vessels: CostCentre[];
|
||||
accounts: AccountNode[];
|
||||
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
|
||||
}
|
||||
|
||||
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
|
||||
export async function getReportDataset(): Promise<ReportDataset> {
|
||||
const [pos, vessels, accounts] = await Promise.all([
|
||||
db.purchaseOrder.findMany({
|
||||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
vesselId: true,
|
||||
accountId: true,
|
||||
totalAmount: true,
|
||||
approvedAt: true,
|
||||
lineItems: { select: { accountId: true, totalPrice: true } },
|
||||
},
|
||||
}),
|
||||
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
|
||||
]);
|
||||
|
||||
const childCount = new Map<string, number>();
|
||||
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
|
||||
const accountNodes: AccountNode[] = accounts.map((a) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
parentId: a.parentId,
|
||||
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
|
||||
}));
|
||||
|
||||
const rows: SpendRow[] = [];
|
||||
for (const po of pos) {
|
||||
if (!po.approvedAt) continue;
|
||||
const meta = {
|
||||
id: po.id,
|
||||
vesselId: po.vesselId,
|
||||
accountId: po.accountId,
|
||||
amount: Number(po.totalAmount),
|
||||
fy: fyStartYear(po.approvedAt),
|
||||
month: fyMonthIndex(po.approvedAt),
|
||||
week: weekOfMonth(po.approvedAt),
|
||||
};
|
||||
rows.push(...allocatePoSpend(meta, po.lineItems.map((l) => ({ accountId: l.accountId, amount: Number(l.totalPrice) }))));
|
||||
}
|
||||
|
||||
const fySet = new Set(rows.map((r) => r.fy));
|
||||
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
|
||||
|
||||
return { rows, vessels, accounts: accountNodes, fys };
|
||||
}
|
||||
|
||||
// ── Account tree helpers ───────────────────────────────────────────────────
|
||||
|
||||
export interface AccountIndex {
|
||||
byId: Map<string, AccountNode>;
|
||||
childrenOf: (parentId: string | null) => AccountNode[];
|
||||
leavesUnder: (id: string) => Set<string>;
|
||||
isLeaf: (id: string) => boolean;
|
||||
pathTo: (id: string) => AccountNode[];
|
||||
}
|
||||
|
||||
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
|
||||
const byId = new Map(accounts.map((a) => [a.id, a]));
|
||||
const kids = new Map<string | null, AccountNode[]>();
|
||||
for (const a of accounts) {
|
||||
const k = a.parentId;
|
||||
if (!kids.has(k)) kids.set(k, []);
|
||||
kids.get(k)!.push(a);
|
||||
}
|
||||
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
|
||||
const isLeaf = (id: string) => childrenOf(id).length === 0;
|
||||
|
||||
const leafCache = new Map<string, Set<string>>();
|
||||
function leavesUnder(id: string): Set<string> {
|
||||
const cached = leafCache.get(id);
|
||||
if (cached) return cached;
|
||||
const out = new Set<string>();
|
||||
const children = childrenOf(id);
|
||||
if (children.length === 0) out.add(id);
|
||||
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
|
||||
leafCache.set(id, out);
|
||||
return out;
|
||||
}
|
||||
function pathTo(id: string): AccountNode[] {
|
||||
const node = byId.get(id);
|
||||
if (!node) return [];
|
||||
return node.parentId ? [...pathTo(node.parentId), node] : [node];
|
||||
}
|
||||
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
|
||||
}
|
||||
|
||||
// ── Aggregations ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface CostCentreSpend {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
total: number; // selected FY
|
||||
months: number[]; // 12 (Apr–Mar) of the selected FY
|
||||
poCount: number; // selected FY
|
||||
fyTotals: number[]; // aligned to ds.fys
|
||||
}
|
||||
|
||||
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
|
||||
const idx = new Map<string, CostCentreSpend>();
|
||||
const poSets = new Map<string, Set<string>>(); // distinct POs per vessel in the selected FY
|
||||
for (const v of ds.vessels) {
|
||||
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
|
||||
poSets.set(v.id, new Set());
|
||||
}
|
||||
for (const r of ds.rows) {
|
||||
const row = idx.get(r.vesselId);
|
||||
if (!row) continue;
|
||||
const fi = ds.fys.indexOf(r.fy);
|
||||
if (fi >= 0) row.fyTotals[fi] += r.amount;
|
||||
if (r.fy === fy) {
|
||||
row.months[r.month] += r.amount;
|
||||
row.total += r.amount;
|
||||
poSets.get(r.vesselId)!.add(r.poId);
|
||||
}
|
||||
}
|
||||
for (const [id, set] of poSets) idx.get(id)!.poCount = set.size;
|
||||
return [...idx.values()];
|
||||
}
|
||||
|
||||
/** Weekly buckets (W1–W5) of one FY month for a cost centre. */
|
||||
export function costCentreWeekly(ds: ReportDataset, vesselId: string, fy: number, month: number): number[] {
|
||||
const weeks = Array(5).fill(0);
|
||||
for (const r of ds.rows) if (r.vesselId === vesselId && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
|
||||
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const months = Array(12).fill(0);
|
||||
const fyTotals = Array(ds.fys.length).fill(0);
|
||||
const poSet = new Set<string>();
|
||||
let total = 0;
|
||||
for (const r of ds.rows) {
|
||||
if (!leaves.has(r.accountId)) continue;
|
||||
const fi = ds.fys.indexOf(r.fy);
|
||||
if (fi >= 0) fyTotals[fi] += r.amount;
|
||||
if (r.fy === fy) {
|
||||
months[r.month] += r.amount;
|
||||
total += r.amount;
|
||||
poSet.add(r.poId);
|
||||
}
|
||||
}
|
||||
return { total, months, fyTotals, poCount: poSet.size };
|
||||
}
|
||||
|
||||
/** Weekly buckets (W1–W5) of one FY month for an account node (rolls leaves up). */
|
||||
export function accountNodeWeekly(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number, month: number): number[] {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const weeks = Array(5).fill(0);
|
||||
for (const r of ds.rows) if (leaves.has(r.accountId) && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export interface NodeSpend {
|
||||
node: AccountNode;
|
||||
total: number;
|
||||
months: number[];
|
||||
fyTotals: number[];
|
||||
poCount: number;
|
||||
}
|
||||
|
||||
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
|
||||
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
|
||||
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
|
||||
}
|
||||
|
||||
export interface Breakdown {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
|
||||
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
|
||||
return ds.accounts
|
||||
.filter((a) => a.tier === tier)
|
||||
.map((a) => {
|
||||
const leaves = idx.leavesUnder(a.id);
|
||||
let value = 0;
|
||||
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
|
||||
return { id: a.id, label: `${a.code} · ${a.name}`, value };
|
||||
})
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
/** For an account-node detail: which cost centres drive its spend, this FY. */
|
||||
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||||
const leaves = idx.leavesUnder(nodeId);
|
||||
const byVessel = new Map<string, number>();
|
||||
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
|
||||
return ds.vessels
|
||||
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
|
||||
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
|
||||
return idx
|
||||
.childrenOf(nodeId)
|
||||
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
|
||||
|
||||
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
|
||||
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
|
||||
|
||||
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
|
||||
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
|
||||
if (scope === "top5") return sortedDesc.slice(0, 5);
|
||||
if (scope === "top10") return sortedDesc.slice(0, 10);
|
||||
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
|
||||
return sortedDesc;
|
||||
}
|
||||
|
||||
export function parseScope(v: string | undefined): ScopeMode {
|
||||
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
|
||||
}
|
||||
export type Granularity = "yearly" | "monthly" | "weekly";
|
||||
export function parseGranularity(v: string | undefined): Granularity {
|
||||
return v === "yearly" || v === "weekly" ? v : "monthly";
|
||||
}
|
||||
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
|
||||
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
|
||||
return ds.fys[ds.fys.length - 1];
|
||||
}
|
||||
/** Resolve the FY-month index (0–11) for weekly mode (default: latest month with spend, else 0). */
|
||||
export function resolveMonth(ds: ReportDataset, fy: number, v: string | undefined): number {
|
||||
const n = v ? Number(v) : NaN;
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 11) return n;
|
||||
let last = 0;
|
||||
for (const r of ds.rows) if (r.fy === fy && r.month > last) last = r.month;
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Parse the `?sel=id1,id2` custom-comparison selection into an ordered, de-duped id list. */
|
||||
export function parseSel(v: string | undefined): string[] {
|
||||
if (!v) return [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of v.split(",")) if (id.trim()) seen.add(id.trim());
|
||||
return [...seen];
|
||||
}
|
||||
/** Toggle an id within a selection list (for the checkbox links). */
|
||||
export function toggleSel(sel: string[], id: string): string[] {
|
||||
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
|
||||
}
|
||||
|
|
@ -59,16 +59,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
|||
return `signatures/${userId}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so
|
||||
* "Email to vendor" can reuse a previously rendered copy instead of re-rendering
|
||||
* and re-uploading on every send (see `prepareVendorEmail`).
|
||||
*/
|
||||
export function buildPoPdfKey(poId: string, fileName: string): string {
|
||||
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `po-pdf/${poId}/${safe}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a company branding asset (logo or stamp/seal).
|
||||
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||
|
|
@ -116,36 +106,6 @@ export async function uploadBuffer(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight existence/metadata check for a stored object (no body transfer).
|
||||
* Returns `{ lastModified }` when the object exists, or `null` when it doesn't.
|
||||
* Used to reuse a cached PO PDF when it's still current.
|
||||
*/
|
||||
export async function statObject(key: string): Promise<{ lastModified: Date } | null> {
|
||||
try {
|
||||
if (isDev) {
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
|
||||
const s = await fs.stat(filePath);
|
||||
return { lastModified: s.mtime };
|
||||
}
|
||||
const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3");
|
||||
const s3 = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }));
|
||||
return { lastModified: r.LastModified ?? new Date(0) };
|
||||
} catch {
|
||||
return null; // missing object (404/NotFound) or any access error → treat as absent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stored file as a Buffer (server-side).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,20 +1,11 @@
|
|||
import { auth } from "@/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
|
||||
|
||||
export default auth((req) => {
|
||||
const isAuthenticated = !!req.auth;
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const isLoginPage = pathname === "/login";
|
||||
|
||||
// PdfService fetches the PO export page unauthenticated, using a `svc` token
|
||||
// that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that
|
||||
// one route through so the service token isn't bounced to /login by the gate
|
||||
// below. Everything else stays auth-protected.
|
||||
if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !isLoginPage) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright config for verifying closed issues against a RUNNING staging instance
|
||||
* (pm2 `ppms-staging`, port 3200 on pms1), reached over an SSH tunnel:
|
||||
*
|
||||
* ssh -N -L 3200:localhost:3200 shad0w@<pms1>
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:3200 \
|
||||
* pnpm exec playwright test --config playwright.staging.config.ts
|
||||
*
|
||||
* Unlike playwright.config.ts this does NOT start a local dev server — it drives the
|
||||
* already-deployed staging build. Login uses the seeded `@pelagia.local` test users
|
||||
* (prisma/seed-test-users.ts), so no production credentials are required.
|
||||
*
|
||||
* Staging runs `next dev`, so the first hit on a route compiles on demand and can be
|
||||
* slow — timeouts are deliberately generous and workers default to 1 to keep the
|
||||
* shared staging DB state predictable across specs.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests/staging",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-staging" }]],
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 20_000 },
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3200",
|
||||
trace: "retain-on-failure",
|
||||
navigationTimeout: 45_000,
|
||||
actionTimeout: 20_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
// Use a system browser channel (Google Chrome) so the suite does not depend on
|
||||
// the bundled chrome-headless-shell download. Override with PW_CHANNEL=msedge
|
||||
// if Chrome is unavailable. Both ship on a standard Windows install.
|
||||
use: { ...devices["Desktop Chrome"], channel: process.env.PW_CHANNEL ?? "chrome" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -1,26 +1,52 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS');
|
||||
-- CreateTable: user-defined T&C categories
|
||||
CREATE TABLE "TermsCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
-- CreateTable
|
||||
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
|
||||
|
||||
-- CreateTable: clauses belonging to a category
|
||||
CREATE TABLE "TermsCondition" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category" "TermsCategory" NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
|
||||
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category");
|
||||
-- Dynamic T&C snapshot on the PO
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;
|
||||
|
||||
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable
|
||||
-- immediately and existing default wording stays selectable.
|
||||
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES
|
||||
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP),
|
||||
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP),
|
||||
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP),
|
||||
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP),
|
||||
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', CURRENT_TIMESTAMP);
|
||||
-- Seed: every standard PO T&C line becomes a catalogued clause. "General" holds
|
||||
-- the previously-fixed boilerplate; the five named slots keep their default
|
||||
-- wording; "Others" is an empty bucket admins fill. isDefault rows pre-fill new POs.
|
||||
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
|
||||
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
|
||||
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
|
||||
('tcat_dispatch', 'Dispatch Instructions',2, CURRENT_TIMESTAMP),
|
||||
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
|
||||
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
|
||||
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
|
||||
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
|
||||
|
||||
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
|
||||
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP),
|
||||
('tcc_delivery', 'tcat_delivery', 'Within 4 to 5 days', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_dispatch', 'tcat_dispatch', 'To be transported to site address as above. Freight Supplier''s A/C', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_inspect', 'tcat_inspect', 'NA', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_transit', 'tcat_transit', 'NA', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_payment', 'tcat_payment', 'Within 30 days from delivery.', true, 0, CURRENT_TIMESTAMP);
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
-- Rework Terms & Conditions (issue #11 follow-up): the fixed TermsCategory ENUM
|
||||
-- becomes a user-defined TermsCategory TABLE; clauses gain isDefault/sortOrder;
|
||||
-- PurchaseOrder gains a JSON `terms` snapshot. Existing enum-based clauses are
|
||||
-- migrated onto the new category rows. Forward migration (the original
|
||||
-- 20260624140000 migration is already released and stays untouched).
|
||||
|
||||
-- Free the "TermsCategory" name (a table and an enum type cannot coexist).
|
||||
ALTER TYPE "TermsCategory" RENAME TO "TermsCategory_old";
|
||||
|
||||
-- New category table.
|
||||
CREATE TABLE "TermsCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
|
||||
|
||||
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
|
||||
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
|
||||
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
|
||||
('tcat_dispatch', 'Dispatch Instructions', 2, CURRENT_TIMESTAMP),
|
||||
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
|
||||
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
|
||||
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
|
||||
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
|
||||
|
||||
-- New clause columns.
|
||||
ALTER TABLE "TermsCondition" ADD COLUMN "categoryId" TEXT;
|
||||
ALTER TABLE "TermsCondition" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "TermsCondition" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Migrate existing clauses from the enum onto category rows.
|
||||
UPDATE "TermsCondition" SET "categoryId" = CASE "category"::text
|
||||
WHEN 'DELIVERY' THEN 'tcat_delivery'
|
||||
WHEN 'DISPATCH' THEN 'tcat_dispatch'
|
||||
WHEN 'INSPECTION' THEN 'tcat_inspect'
|
||||
WHEN 'TRANSIT_INSURANCE' THEN 'tcat_transit'
|
||||
WHEN 'PAYMENT_TERMS' THEN 'tcat_payment'
|
||||
END;
|
||||
|
||||
-- The original seed clauses become the PO defaults.
|
||||
UPDATE "TermsCondition" SET "isDefault" = true
|
||||
WHERE "id" IN ('tcseed_delivery','tcseed_dispatch','tcseed_inspect','tcseed_transit','tcseed_payment');
|
||||
|
||||
-- Drop the old enum column + type now that data is migrated.
|
||||
DROP INDEX "TermsCondition_category_idx";
|
||||
ALTER TABLE "TermsCondition" DROP COLUMN "category";
|
||||
DROP TYPE "TermsCategory_old";
|
||||
|
||||
-- Enforce the relation.
|
||||
ALTER TABLE "TermsCondition" ALTER COLUMN "categoryId" SET NOT NULL;
|
||||
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
|
||||
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Seed the previously-fixed boilerplate lines as default "General" clauses.
|
||||
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
|
||||
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
|
||||
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Dynamic T&C snapshot on the PO.
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;
|
||||
|
|
@ -22,8 +22,8 @@ export type RankEntry = {
|
|||
|
||||
export const RANKS: RankEntry[] = [
|
||||
// ── Management (portal logins) ──────────────────────────────────────────────
|
||||
{ code: "PM", name: "Project Manager", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "APM", name: "Assistant Project Manager", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "PM", name: "PM", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "APM", name: "Assistant PM", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "SIC", name: "Site In-charge", parentCode: "APM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
|
||||
// ── Shore support (no login, no seafarer docs) ──────────────────────────────
|
||||
|
|
@ -34,15 +34,15 @@ export const RANKS: RankEntry[] = [
|
|||
|
||||
// ── Operational crew (seafarers) ────────────────────────────────────────────
|
||||
{ code: "DIC", name: "Dredger In-charge", parentCode: "SIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SDO", name: "Senior Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SDO", name: "Sr. Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "PLS", name: "Pipeline Supervisor", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "PLA", name: "Pipeline Assistant", parentCode: "PLS", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "JDO", name: "Junior Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "JDO", name: "Jr. Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "ERO", name: "Engine Room Operator", parentCode: "JDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "DH", name: "Deck Hand", parentCode: "ERO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "TR", name: "Trainee", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "MB", name: "Mess Boy", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "ELE", name: "Electrician", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SFB", name: "Senior Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SFB", name: "Sr. Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "FW", name: "Fabricator / Welder", parentCode: "SFB", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* Seed deterministic, credential-capable TEST USERS into a database.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* `pelagia_test` (the staging / autofix DB) is a daily mirror of production, so it
|
||||
* only contains real `@pelagiamarine.com` users — most are SSO-only (no password)
|
||||
* and none have a password we know. That makes it impossible to log into the
|
||||
* staging instance (port 3200) with the credentials provider to run end-to-end
|
||||
* feature tests.
|
||||
*
|
||||
* This script upserts one **known-password** user per `Role` (using the throwaway
|
||||
* `@pelagia.local` domain, which never exists in prod, so there is zero collision
|
||||
* with real accounts). Credentials intentionally mirror
|
||||
* `tests/e2e/helpers/login.ts` so the same Playwright specs run locally and against
|
||||
* staging unchanged.
|
||||
*
|
||||
* Safety
|
||||
* ------
|
||||
* - Idempotent: upsert keyed on the (unique) email; re-running only refreshes the
|
||||
* password hash / role / isActive.
|
||||
* - `employeeId` uses a `TEST-*` prefix so it can never clash with a real
|
||||
* production employee id carried over by the mirror.
|
||||
* - Only ever creates the `@pelagia.local` users below — it touches no prod rows.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* DATABASE_URL="postgresql://.../pelagia_test" pnpm tsx prisma/seed-test-users.ts
|
||||
*
|
||||
* It is wired into `automation/refresh-test-db.sh` so these accounts are recreated
|
||||
* automatically after every daily refresh of `pelagia_test`.
|
||||
*/
|
||||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* One login per role/flow that the closed-issue feature tests exercise.
|
||||
* Passwords match tests/e2e/helpers/login.ts (do not change one without the other).
|
||||
*/
|
||||
const TEST_USERS: Array<{
|
||||
employeeId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}> = [
|
||||
{ employeeId: "TEST-TECH", email: "tech@pelagia.local", name: "Test Technical", password: "tech1234", role: Role.TECHNICAL },
|
||||
{ employeeId: "TEST-MANNING",email: "manning@pelagia.local", name: "Test Manning", password: "manning1234", role: Role.MANNING },
|
||||
{ employeeId: "TEST-ACCT", email: "accounts@pelagia.local", name: "Test Accounts", password: "accounts1234", role: Role.ACCOUNTS },
|
||||
{ employeeId: "TEST-MGR", email: "manager@pelagia.local", name: "Test Manager", password: "manager1234", role: Role.MANAGER },
|
||||
{ employeeId: "TEST-SUPER", email: "superuser@pelagia.local", name: "Test Superuser", password: "super1234", role: Role.SUPERUSER },
|
||||
{ employeeId: "TEST-AUDIT", email: "auditor@pelagia.local", name: "Test Auditor", password: "audit1234", role: Role.AUDITOR },
|
||||
{ employeeId: "TEST-ADMIN", email: "admin@pelagia.local", name: "Test Admin", password: "admin1234", role: Role.ADMIN },
|
||||
{ employeeId: "TEST-SITE", email: "site@pelagia.local", name: "Test Site Staff", password: "site1234", role: Role.SITE_STAFF},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding ${TEST_USERS.length} test users...`);
|
||||
for (const u of TEST_USERS) {
|
||||
const passwordHash = await bcrypt.hash(u.password, 12);
|
||||
await prisma.user.upsert({
|
||||
where: { email: u.email },
|
||||
// Keep an existing test account in sync (refresh the hash / role / active flag)
|
||||
// but never overwrite its employeeId once created.
|
||||
update: { name: u.name, passwordHash, role: u.role, isActive: true },
|
||||
create: {
|
||||
employeeId: u.employeeId,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
passwordHash,
|
||||
role: u.role,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log(` ✓ ${u.email.padEnd(28)} ${u.role}`);
|
||||
}
|
||||
console.log("Test users ready.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("seed-test-users failed:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
* - After adding an item to the cart, the badge count on the cart icon increases
|
||||
*
|
||||
* Feature 15 — Inventory item & vendor detail pages
|
||||
* - Clicking an item on /catalogue/items navigates to /catalogue/items/[id]
|
||||
* - Clicking an item on /inventory/items navigates to /inventory/items/[id]
|
||||
* - The item detail shows name, price, vendor info
|
||||
* - /catalogue/vendors/[id] shows vendor details
|
||||
* - /inventory/vendors/[id] shows vendor details
|
||||
*
|
||||
* Created: 2026-05-17
|
||||
*/
|
||||
|
|
@ -52,7 +52,7 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
|
|||
await login(page, USERS.TECH);
|
||||
|
||||
// Navigate to inventory items
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const rows = page.locator("tbody tr");
|
||||
|
|
@ -97,15 +97,15 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
|
|||
});
|
||||
|
||||
test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
|
||||
test("US-15a: clicking an item row navigates to /catalogue/items/[id]", async ({
|
||||
test("US-15a: clicking an item row navigates to /inventory/items/[id]", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for a direct link to an item detail page
|
||||
const itemLink = page.locator("a[href*='/catalogue/items/']").first();
|
||||
const itemLink = page.locator("a[href*='/inventory/items/']").first();
|
||||
if (await itemLink.isVisible()) {
|
||||
await itemLink.click();
|
||||
await expect(page).toHaveURL(/\/inventory\/items\/.+/);
|
||||
|
|
@ -150,14 +150,14 @@ test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
|
|||
console.log(`✓ Item detail page loaded: ${page.url()}`);
|
||||
});
|
||||
|
||||
test("US-15b: /catalogue/vendors/[id] shows vendor details for TECHNICAL user", async ({
|
||||
test("US-15b: /inventory/vendors/[id] shows vendor details for TECHNICAL user", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/vendors");
|
||||
await page.goto("/inventory/vendors");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const vendorLink = page.locator("a[href*='/catalogue/vendors/']").first();
|
||||
const vendorLink = page.locator("a[href*='/inventory/vendors/']").first();
|
||||
if (await vendorLink.isVisible()) {
|
||||
await vendorLink.click();
|
||||
await expect(page).toHaveURL(/\/inventory\/vendors\/.+/);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* User stories covered: Feature 12 — Cheapest & Closest tags
|
||||
* - TECHNICAL user on /catalogue/items sees Cheapest or Closest tags on item rows
|
||||
* - TECHNICAL user on /inventory/items sees Cheapest or Closest tags on item rows
|
||||
* when a site is selected (tags are independent of sort order)
|
||||
*
|
||||
* Feature 13 — Auto-sort by distance when site selected
|
||||
|
|
@ -17,11 +17,11 @@ import { test, expect } from "@playwright/test";
|
|||
import { login, USERS } from "../helpers/login";
|
||||
|
||||
test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
||||
test("US-12a: /catalogue/items page loads for TECHNICAL user", async ({
|
||||
test("US-12a: /inventory/items page loads for TECHNICAL user", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Page should show some items (table rows or empty state)
|
||||
|
|
@ -41,7 +41,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Select a site to enable distance computation
|
||||
|
|
@ -54,7 +54,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
}
|
||||
|
||||
// Navigate to items with site selected (wait for URL param)
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -106,7 +106,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -116,7 +116,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -148,7 +148,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -158,7 +158,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -176,7 +176,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Expand a row to reveal sort toggle
|
||||
|
|
@ -196,7 +196,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
}
|
||||
|
||||
// Select a site — row stays expanded (preserved React state through soft nav)
|
||||
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -223,7 +223,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
page,
|
||||
}) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/catalogue/items");
|
||||
await page.goto("/inventory/items");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const siteSelect = page.locator("select").first();
|
||||
|
|
@ -234,7 +234,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
|
|||
}
|
||||
|
||||
// Select a site
|
||||
const nav1 = page.waitForURL("**/catalogue/items?siteId=**", {
|
||||
const nav1 = page.waitForURL("**/inventory/items?siteId=**", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await siteSelect.selectOption({ index: 1 });
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* Integration tests for manager approval server actions.
|
||||
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
|
@ -47,12 +47,6 @@ afterEach(async () => {
|
|||
await deletePosByTitle(PREFIX);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Products auto-created by the catalogue-on-approval test.
|
||||
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
|
||||
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||
});
|
||||
|
||||
// Helper: create a PO in MGR_REVIEW state
|
||||
async function createSubmittedPo(title: string): Promise<string> {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
|
@ -165,40 +159,6 @@ describe("issue #92 — advance payment on approval", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Product catalogue registered on approval (so items are reusable) ─────────
|
||||
|
||||
describe("product catalogue on approval", () => {
|
||||
it("creates a catalogue product for a free-text line item and links it", async () => {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const itemName = `${PREFIX}Starter VPS hosting`;
|
||||
const form = makePoForm({
|
||||
title: `${PREFIX}CatApprove`,
|
||||
vesselId,
|
||||
accountId,
|
||||
intent: "submit",
|
||||
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
|
||||
});
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||
|
||||
// No catalogue product exists for this name before approval.
|
||||
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
expect(await approvePo({ poId })).toEqual({ ok: true });
|
||||
|
||||
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
|
||||
expect(product).not.toBeNull();
|
||||
expect(Number(product!.lastPrice)).toBe(459.95);
|
||||
|
||||
// The line item is linked back to the new product, and a per-vendor price is recorded.
|
||||
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
|
||||
expect(li.productId).toBe(product!.id);
|
||||
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
|
||||
expect(pvp).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── M-03: Reject ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("M-03 — reject PO", () => {
|
||||
|
|
|
|||
|
|
@ -17,15 +17,13 @@ vi.mock("@/lib/storage", async (importOriginal) => {
|
|||
...actual,
|
||||
uploadBuffer: vi.fn(async () => {}),
|
||||
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
|
||||
statObject: vi.fn(async () => null), // default: no cached object → render
|
||||
};
|
||||
});
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
|
||||
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
|
||||
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
|
||||
import { isPdfServiceConfigured } from "@/lib/pdf-service";
|
||||
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
|
||||
|
||||
const PREFIX = "INTTEST_EMAILVENDOR_";
|
||||
|
|
@ -100,34 +98,6 @@ describe("prepareVendorEmail", () => {
|
|||
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
|
||||
});
|
||||
|
||||
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
|
||||
as(techId, "TECHNICAL");
|
||||
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
||||
|
||||
// 1st send: no cached object → render + upload once.
|
||||
vi.mocked(statObject).mockResolvedValueOnce(null);
|
||||
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
|
||||
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
|
||||
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
|
||||
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
|
||||
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
|
||||
});
|
||||
|
||||
it("re-renders when the PO changed since the cached copy", async () => {
|
||||
as(techId, "TECHNICAL");
|
||||
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
||||
// Cached object older than the PO's updatedAt → stale → re-render.
|
||||
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
|
||||
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
||||
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
|
||||
as(techId, "TECHNICAL");
|
||||
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Foundation check: the staging instance is reachable and every seeded test user
|
||||
* can authenticate with the credentials provider. If this fails, none of the
|
||||
* per-issue specs can run — fix the seed (prisma/seed-test-users.ts) or the tunnel.
|
||||
*/
|
||||
test.describe("staging smoke", () => {
|
||||
test("login page renders the staging build", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByLabel(/email address/i)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const [name, creds] of Object.entries(USERS)) {
|
||||
test(`seeded user ${name} can log in`, async ({ page }) => {
|
||||
await login(page, creds);
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Crewing epics (#75 Requisitions, #76 Candidates, #79 Crew records, #81 Leave &
|
||||
* Attendance, #83 Office verification, #86 Reference data/admin) — feature-flagged
|
||||
* behind NEXT_PUBLIC_CREWING_ENABLED, which is "true" on staging.
|
||||
*
|
||||
* These are smoke checks that each epic's primary surface renders for an authorised
|
||||
* role (the deep state-machine flows — pipeline #77, onboarding #78, PPE #80,
|
||||
* appraisal #82, sign-off #85 — are covered by the existing integration suites noted
|
||||
* in Docs/TESTING.md). Render-without-redirect is the proof the shipped feature is
|
||||
* live on staging.
|
||||
*/
|
||||
|
||||
const PAGES: Array<{ issue: string; name: string; path: string; user: keyof typeof USERS; heading: RegExp }> = [
|
||||
{ issue: "#75", name: "Requisitions", path: "/crewing/requisitions", user: "MANAGER", heading: /requisition/i },
|
||||
{ issue: "#76", name: "Candidates", path: "/crewing/candidates", user: "MANAGER", heading: /candidate/i },
|
||||
{ issue: "#79", name: "Crew records", path: "/crewing/crew", user: "MANAGER", heading: /crew/i },
|
||||
{ issue: "#81", name: "Leave", path: "/crewing/leave", user: "MANAGER", heading: /leave/i },
|
||||
{ issue: "#81", name: "Attendance", path: "/crewing/attendance", user: "MANAGER", heading: /attendance/i },
|
||||
{ issue: "#83", name: "Verification", path: "/crewing/verification", user: "MANNING", heading: /verif/i },
|
||||
{ issue: "#86", name: "Ranks", path: "/admin/ranks", user: "MANAGER", heading: /rank/i },
|
||||
{ issue: "#86", name: "Crew admin", path: "/admin/crew", user: "MANAGER", heading: /crew/i },
|
||||
];
|
||||
|
||||
for (const p of PAGES) {
|
||||
test(`${p.issue} ${p.name} surface renders on staging (${p.path})`, async ({ page }) => {
|
||||
await login(page, USERS[p.user]);
|
||||
await page.goto(p.path);
|
||||
await expect(page, `should not redirect away from ${p.path}`).toHaveURL(new RegExp(p.path.replace(/\//g, "\\/")));
|
||||
await expect(page.getByRole("heading", { name: p.heading }).first()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* Runtime fixture lookups for the staging verification suite.
|
||||
*
|
||||
* PO ids in `pelagia_test` change on every daily refresh, so specs must not hard-code
|
||||
* them. These helpers anchor each spec on a real row that currently exists in the
|
||||
* staging DB (read-only), keeping the suite stable across refreshes. They use the
|
||||
* SAME `DATABASE_URL` the Playwright run is given (the SSH tunnel to pelagia_test).
|
||||
*
|
||||
* This is test *setup* only — every assertion still runs against the live UI.
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let _db: PrismaClient | null = null;
|
||||
export function db(): PrismaClient {
|
||||
if (!_db) _db = new PrismaClient();
|
||||
return _db;
|
||||
}
|
||||
export async function closeDb() {
|
||||
if (_db) await _db.$disconnect();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
// Mirrors lib/utils.ts POST_APPROVAL_STATUSES — the statuses that count as "approved
|
||||
// and beyond" for spend/approval trackers (issues #12, #32, #50).
|
||||
export const POST_APPROVAL_STATUSES = [
|
||||
"MGR_APPROVED",
|
||||
"SENT_FOR_PAYMENT",
|
||||
"PARTIALLY_PAID",
|
||||
"PAID_DELIVERED",
|
||||
"PARTIALLY_CLOSED",
|
||||
"CLOSED",
|
||||
] as const;
|
||||
|
||||
/** First day of the current month in local time (matches the dashboard query). */
|
||||
export function startOfMonth(now = new Date()): Date {
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
/** Mirror of lib/utils.ts formatDate → e.g. "Jun 11, 2026". */
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
}
|
||||
|
||||
export async function approvedPo() {
|
||||
return db().purchaseOrder.findFirst({
|
||||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
|
||||
orderBy: { approvedAt: "desc" },
|
||||
select: { id: true, poNumber: true, status: true, approvedAt: true, poDate: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An approved-or-later PO with NO explicit poDate, so its detail "PO Date" must fall
|
||||
* back to the approval date — the exact case issue #5 is about.
|
||||
*/
|
||||
export async function approvedPoNoPoDate() {
|
||||
return db().purchaseOrder.findFirst({
|
||||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null }, poDate: null },
|
||||
orderBy: { approvedAt: "desc" },
|
||||
select: { id: true, poNumber: true, status: true, approvedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function closedPo() {
|
||||
return db().purchaseOrder.findFirst({
|
||||
where: { status: "CLOSED" },
|
||||
select: { id: true, poNumber: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** An approved-or-later PO whose vendor has a contact email (issue #14). */
|
||||
export async function poWithVendorEmail() {
|
||||
return db().purchaseOrder.findFirst({
|
||||
where: {
|
||||
status: { in: [...POST_APPROVAL_STATUSES] },
|
||||
vendor: { contacts: { some: { email: { not: null } } } },
|
||||
},
|
||||
select: { id: true, poNumber: true, status: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** A PO that has at least one line item with a non-empty description (issue #8). */
|
||||
export async function poWithLineItemDescription() {
|
||||
const li = await db().pOLineItem.findFirst({
|
||||
where: { description: { not: null }, AND: [{ description: { not: "" } }] },
|
||||
select: { description: true, po: { select: { id: true, poNumber: true, status: true } } },
|
||||
});
|
||||
return li ? { ...li.po, description: li.description as string } : null;
|
||||
}
|
||||
|
||||
/** All CLOSED PO numbers, to assert the manager sees the full closed set (issue #6). */
|
||||
export async function closedPoNumbers() {
|
||||
const rows = await db().purchaseOrder.findMany({
|
||||
where: { status: "CLOSED" },
|
||||
select: { poNumber: true },
|
||||
});
|
||||
return rows.map((r) => r.poNumber);
|
||||
}
|
||||
|
||||
/** A PO that has at least one uploaded document, to exercise attachment grouping (#10). */
|
||||
export async function poWithDocuments() {
|
||||
const doc = await db().pODocument.findFirst({
|
||||
select: { po: { select: { id: true, poNumber: true } } },
|
||||
});
|
||||
return doc?.po ?? null;
|
||||
}
|
||||
|
||||
export async function totalPoCount() {
|
||||
return db().purchaseOrder.count();
|
||||
}
|
||||
|
||||
/** Count of POs approved in the current month, regardless of current status (issues #12/#32). */
|
||||
export async function approvedThisMonthCount() {
|
||||
return db().purchaseOrder.count({
|
||||
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { gte: startOfMonth() } },
|
||||
});
|
||||
}
|
||||
|
||||
/** A vendor that has a non-null vendorId code, for search-by-code specs (#57/#109). */
|
||||
export async function vendorWithCode() {
|
||||
return db().vendor.findFirst({
|
||||
where: { vendorId: { not: null } },
|
||||
select: { name: true, vendorId: true },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Shared helpers for the staging closed-issue verification suite.
|
||||
*
|
||||
* Credentials mirror prisma/seed-test-users.ts (seeded into pelagia_test) and the
|
||||
* dev-seed users in tests/e2e/helpers/login.ts.
|
||||
*/
|
||||
import { type Page, expect } from "@playwright/test";
|
||||
|
||||
export interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const USERS = {
|
||||
TECH: { email: "tech@pelagia.local", password: "tech1234" },
|
||||
MANNING: { email: "manning@pelagia.local", password: "manning1234" },
|
||||
ACCOUNTS: { email: "accounts@pelagia.local", password: "accounts1234" },
|
||||
MANAGER: { email: "manager@pelagia.local", password: "manager1234" },
|
||||
SUPERUSER: { email: "superuser@pelagia.local", password: "super1234" },
|
||||
AUDITOR: { email: "auditor@pelagia.local", password: "audit1234" },
|
||||
ADMIN: { email: "admin@pelagia.local", password: "admin1234" },
|
||||
SITE: { email: "site@pelagia.local", password: "site1234" },
|
||||
} satisfies Record<string, Credentials>;
|
||||
|
||||
/** Log in via the credentials provider and wait until off the /login page. */
|
||||
export async function login(page: Page, creds: Credentials): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/email address/i).fill(creds.email);
|
||||
await page.getByLabel(/password/i).fill(creds.password);
|
||||
// The staging login page has two buttons: "Sign in with Microsoft" (SSO) and the
|
||||
// credentials "Sign in" submit. Target the exact credentials submit.
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** Log out via the header control (best-effort; ignores if already logged out). */
|
||||
export async function logout(page: Page): Promise<void> {
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #4 — Submitter can set an optional PO date (back/forward-datable).
|
||||
* Fix: a `poDate` date input on the PO create/edit forms.
|
||||
*/
|
||||
test("#4 PO create form exposes an optional, free-to-set PO Date field", async ({ page }) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/po/new");
|
||||
|
||||
const poDate = page.locator('input[name="poDate"]');
|
||||
await expect(poDate).toBeVisible();
|
||||
await expect(poDate).toHaveAttribute("type", "date");
|
||||
await expect(poDate).not.toHaveAttribute("required", "");
|
||||
|
||||
// It accepts a back-dated value and a forward-dated value.
|
||||
await poDate.fill("2024-01-15");
|
||||
await expect(poDate).toHaveValue("2024-01-15");
|
||||
await poDate.fill("2030-12-31");
|
||||
await expect(poDate).toHaveValue("2030-12-31");
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { approvedPoNoPoDate, formatDate, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #5 — Once a PO is approved, the PO date shown is the approval date
|
||||
* (when the submitter did not set an explicit poDate). Display rule:
|
||||
* poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#5 approved PO detail shows the approval date as the PO Date", async ({ page }) => {
|
||||
const po = await approvedPoNoPoDate();
|
||||
test.skip(!po, "no approved PO without an explicit poDate in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto(`/po/${po!.id}`);
|
||||
|
||||
await expect(page.getByText(po!.poNumber)).toBeVisible();
|
||||
// The "PO Date" detail row should render the approval date.
|
||||
const expected = formatDate(po!.approvedAt!);
|
||||
const row = page.getByText("PO Date", { exact: true }).locator("xpath=ancestor::*[1]");
|
||||
await expect(row).toContainText(expected);
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { closedPoNumbers, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #6 — Closed PO list filters.
|
||||
* - MANAGER: the "Closed Purchase Orders" view shows ALL closed POs.
|
||||
* - Submitter: the view shows ONLY CLOSED (no APPROVED leaking in).
|
||||
* Route: /my-orders.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#6 manager sees ALL closed POs on /my-orders", async ({ page }) => {
|
||||
const closed = await closedPoNumbers();
|
||||
test.skip(closed.length === 0, "no closed POs in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/my-orders");
|
||||
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
|
||||
|
||||
// The manager sees the FULL closed set (not just their own): every closed PO number
|
||||
// is present, and no APPROVED status leaks into this view.
|
||||
for (const poNumber of closed) {
|
||||
await expect(page.getByText(poNumber).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("#6 submitter's closed view excludes APPROVED POs", async ({ page }) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/my-orders");
|
||||
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
|
||||
// The bug was APPROVED POs showing here; assert none do.
|
||||
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { poWithLineItemDescription, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #8 — The exported PO must include the line item's optional description.
|
||||
* The export route renders the printable PO at /api/po/[id]/export?format=pdf;
|
||||
* we fetch it with the logged-in session and assert the description text is present.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#8 exported PO contains the line-item description", async ({ page }) => {
|
||||
const po = await poWithLineItemDescription();
|
||||
test.skip(!po, "no PO with a line-item description in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
// page.request shares the authenticated browser cookies.
|
||||
const res = await page.request.get(`/api/po/${po!.id}/export?format=pdf`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.text();
|
||||
expect(body).toContain(po!.description);
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { poWithDocuments, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #10 — PO detail shows ALL attachments, grouped by type
|
||||
* (Submission / Payment / Delivery). Requires a PO that actually has documents;
|
||||
* if the staging mirror has none, the spec skips (documented data limitation).
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#10 PO detail groups attachments by type", async ({ page }) => {
|
||||
const po = await poWithDocuments();
|
||||
test.skip(!po, "no PO with uploaded documents in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto(`/po/${po!.id}`);
|
||||
await expect(page.getByText(po!.poNumber)).toBeVisible();
|
||||
|
||||
// The attachments region renders at least one of the typed group headings.
|
||||
const groups = page.getByText(
|
||||
/SUBMISSION DOCUMENTS|PAYMENT DOCUMENTS|DELIVERY RECEIPTS|OTHER ATTACHMENTS/i,
|
||||
);
|
||||
await expect(groups.first()).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #104 — /history is paginated with an items-per-page dropdown.
|
||||
*/
|
||||
test("#104 history has an items-per-page dropdown that drives a perPage query param", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/history");
|
||||
|
||||
const perPage = page.locator("#perPage");
|
||||
await expect(perPage).toBeVisible();
|
||||
// Options include the configured page sizes.
|
||||
await expect(perPage.locator('option[value="50"]')).toBeAttached();
|
||||
|
||||
await perPage.selectOption("50");
|
||||
await expect(page).toHaveURL(/perPage=50/);
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { vendorWithCode, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #109 — the New PO screen vendor field is a searchable combobox that matches
|
||||
* by vendor name AND code (mirrors the items search).
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#109 new PO vendor field is a searchable combobox (name + code)", async ({ page }) => {
|
||||
const vendor = await vendorWithCode();
|
||||
test.skip(!vendor, "no vendor with a code in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/po/new");
|
||||
|
||||
// Open the vendor combobox (trigger shows the "No vendor selected" placeholder).
|
||||
await page.getByText("No vendor selected").click();
|
||||
|
||||
const search = page.getByPlaceholder(/Search by name or code/i);
|
||||
await expect(search).toBeVisible();
|
||||
|
||||
// Searching by the vendor's CODE surfaces the vendor by name — proving code search.
|
||||
await search.fill(vendor!.vendorId!);
|
||||
await expect(page.getByText(vendor!.name, { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #11 — Admin-managed Terms & Conditions catalogue feeding a dynamic PO editor.
|
||||
* - Admin surface at /admin/terms.
|
||||
* - The PO create form renders a Terms & Conditions editor.
|
||||
*/
|
||||
test("#11 admin Terms & Conditions page renders for a manager", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/admin/terms");
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByRole("heading", { name: /terms.*conditions/i }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("#11 new PO form includes a Terms & Conditions editor", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/po/new");
|
||||
await expect(page.getByText(/terms.*conditions/i).first()).toBeVisible();
|
||||
// The dynamic editor offers an "Add term" affordance.
|
||||
await expect(page.getByRole("button", { name: /add term/i })).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { approvedThisMonthCount, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #12 — Manager dashboard 'Total approved this month' was stuck at 0 / not
|
||||
* updating. Fix: the card counts every PO approved this month (any post-approval
|
||||
* status). We assert the rendered value matches the DB-computed count, proving it
|
||||
* reflects real data rather than 0.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#12 'Approved This Month' card shows the correct, live count", async ({ page }) => {
|
||||
const expected = await approvedThisMonthCount();
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const card = page.locator("a,div", { has: page.getByText("Approved This Month", { exact: true }) }).first();
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(String(expected));
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #13 — Accounts dashboard should have a 'Payments completed this month' card
|
||||
* (analogous to the manager's monthly approval card).
|
||||
*
|
||||
* VERIFICATION RESULT: NOT FIXED on staging. The Accounts dashboard currently shows
|
||||
* only "Ready for Payment" and "Payment Queue Value" — there is no
|
||||
* payments-completed-this-month card. This is marked test.fail() so the suite stays
|
||||
* green while clearly recording the gap; if the card is later added, this test will
|
||||
* start passing and flag that the annotation should be removed.
|
||||
*/
|
||||
test.fail(true, "Issue #13 not implemented: no 'payments completed this month' card on the Accounts dashboard");
|
||||
|
||||
test("#13 Accounts dashboard shows a 'Payments completed this month' card", async ({ page }) => {
|
||||
await login(page, USERS.ACCOUNTS);
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText(/payments? completed this month/i)).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { poWithVendorEmail, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #14 — 'Email to vendor' option becomes available once a PO is approved (and
|
||||
* after payment), when the vendor has a contact email. We assert the button is
|
||||
* present on such a PO. (The underlying PDF/Outlook pipeline depends on PdfService
|
||||
* env config; this verifies the user-facing affordance exists.)
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#14 approved PO with a vendor email shows the 'Email to vendor' button", async ({ page }) => {
|
||||
const po = await poWithVendorEmail();
|
||||
test.skip(!po, "no approved PO with a vendor contact email in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto(`/po/${po!.id}`);
|
||||
await expect(page.getByText(po!.poNumber)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /email to vendor/i })).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #19 — Place of Delivery is a dropdown (admin-managed delivery locations),
|
||||
* not free text. The PO forms render <select name="placeOfDelivery">, and the admin
|
||||
* surface lives at /admin/delivery-locations.
|
||||
*/
|
||||
test("#19 PO form Place of Delivery is a <select> dropdown", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/po/new");
|
||||
const select = page.locator('select[name="placeOfDelivery"]');
|
||||
await expect(select).toBeVisible();
|
||||
});
|
||||
|
||||
test("#19 admin delivery-locations page renders for a manager", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/admin/delivery-locations");
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByRole("heading", { name: /delivery location/i }).first()).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issues #24 / #40 — change the sign-out control's tooltip from 'Sign out' to
|
||||
* 'Log out'. Both were pipeline / button-simulation TEST issues.
|
||||
*
|
||||
* VERIFICATION RESULT: NOT FIXED on staging. The header logout control still uses
|
||||
* title="Sign out". Marked test.fail() to record the gap without failing the suite;
|
||||
* if the copy is changed to 'Log out' this test will pass and flag the annotation.
|
||||
*/
|
||||
test.fail(true, "Issues #24/#40 not implemented: logout tooltip is still 'Sign out'");
|
||||
|
||||
test("#24/#40 logout control tooltip reads 'Log out'", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('[title="Log out"]')).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { totalPoCount, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #41 — the dashboard 'Total Purchase Orders' card shows the correct count.
|
||||
* Issue #26 — that stat card is clickable and links to the history page.
|
||||
* Both are verified on the generic dashboard (AUDITOR role).
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#41 'Total Purchase Orders' card shows the correct unfiltered count", async ({ page }) => {
|
||||
const expected = await totalPoCount();
|
||||
await login(page, USERS.AUDITOR);
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const card = page.getByRole("link").filter({ hasText: "Total Purchase Orders" });
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText(String(expected));
|
||||
});
|
||||
|
||||
test("#26 'Total Purchase Orders' card links to the history page", async ({ page }) => {
|
||||
await login(page, USERS.AUDITOR);
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("link").filter({ hasText: "Total Purchase Orders" }).click();
|
||||
await expect(page).toHaveURL(/\/history/);
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #31 — PO history allows selecting MULTIPLE statuses (OR-ed). The status
|
||||
* filter is a checkbox dropdown; applying two statuses yields two `status` query
|
||||
* params.
|
||||
*/
|
||||
test("#31 history status filter supports multiple OR-ed statuses", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/history");
|
||||
|
||||
// Open the status dropdown (button label is "All statuses" / "N statuses").
|
||||
await page.getByRole("button", { name: /statuses/i }).click();
|
||||
await page.getByRole("checkbox", { name: "Closed" }).check();
|
||||
await page.getByRole("checkbox", { name: "Approved" }).check();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/status=CLOSED/);
|
||||
await expect(page).toHaveURL(/status=MGR_APPROVED/);
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #32 — the manager 'Approved This Month' card counts POs approved this month
|
||||
* regardless of their current status, and clicking it opens history pre-filtered by
|
||||
* approval date (?approvedFrom=YYYY-MM-01).
|
||||
*/
|
||||
test("#32 'Approved This Month' card links to history filtered by approval date", async ({ page }) => {
|
||||
const now = new Date();
|
||||
const expectedParam = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const card = page.getByRole("link").filter({ hasText: "Approved This Month" });
|
||||
await expect(card).toBeVisible();
|
||||
await card.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/history\\?approvedFrom=${expectedParam}`));
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #44 — the PO line-item unit dropdown must offer months and year(s)
|
||||
* (previously only days/short units).
|
||||
*/
|
||||
test("#44 line-item unit dropdown includes month and year options", async ({ page }) => {
|
||||
await login(page, USERS.TECH);
|
||||
await page.goto("/po/new");
|
||||
|
||||
// The line-items editor renders at least one unit <select>; assert the new options.
|
||||
await expect(page.locator('option[value="month"]').first()).toBeAttached();
|
||||
await expect(page.locator('option[value="year"]').first()).toBeAttached();
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #50 — the manager approved-spend card uses the rupee symbol and compact
|
||||
* Indian formatting (₹… with L / Cr / K), not a dollar sign.
|
||||
*/
|
||||
test("#50 'Total Approved Spend' card shows ₹ with compact L/Cr/K formatting", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const card = page.locator("div").filter({ has: page.getByText("Total Approved Spend", { exact: true }) }).first();
|
||||
await expect(card).toBeVisible();
|
||||
// ₹ present, no dollar sign, value matches the compact format (₹2 Cr / ₹49 L / ₹75 K / ₹500).
|
||||
await expect(card).toContainText("₹");
|
||||
await expect(card).not.toContainText("$");
|
||||
await expect(card).toContainText(/₹\s?-?\d[\d.,]*\s?(Cr|L|K)?/);
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { approvedPo, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #53 — Managers can cancel a PO via a confirmation modal that requires a
|
||||
* reason and typing the word "cancel". This spec verifies the affordance and the
|
||||
* type-to-confirm guard WITHOUT actually cancelling (non-destructive on staging
|
||||
* data): it confirms the submit button stays disabled until "cancel" is typed, then
|
||||
* closes the modal.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#53 Cancel PO modal enforces type-'cancel'-to-confirm", async ({ page }) => {
|
||||
const po = await approvedPo();
|
||||
test.skip(!po, "no approved PO in staging data to exercise the cancel control");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto(`/po/${po!.id}`);
|
||||
|
||||
await page.getByRole("button", { name: "Cancel PO" }).click();
|
||||
|
||||
const submit = page.getByRole("button", { name: "Cancel this PO" });
|
||||
await expect(submit).toBeVisible();
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// A reason alone is not enough.
|
||||
await page.getByPlaceholder(/Duplicate order/i).fill("Verification test — not a real cancellation");
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// Typing the confirmation word enables it.
|
||||
await page.getByPlaceholder("cancel").fill("cancel");
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
// Back out without cancelling.
|
||||
await page.getByRole("button", { name: "Keep PO" }).click();
|
||||
await expect(submit).toBeHidden();
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
import { vendorWithCode, closeDb } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Issue #57 — /catalogue/vendors (formerly /inventory/vendors) is searchable by
|
||||
* vendor id/code, and the id is shown next to the name.
|
||||
*/
|
||||
test.afterAll(closeDb);
|
||||
|
||||
test("#57 vendors are searchable by vendor id, with the id shown next to the name", async ({ page }) => {
|
||||
const vendor = await vendorWithCode();
|
||||
test.skip(!vendor, "no vendor with a vendorId code in staging data");
|
||||
|
||||
await login(page, USERS.MANAGER);
|
||||
await page.goto("/catalogue/vendors");
|
||||
|
||||
await page.getByPlaceholder(/Search by name, ID, GSTIN/i).fill(vendor!.vendorId!);
|
||||
// The matching vendor row shows both the name and the id badge.
|
||||
await expect(page.getByText(vendor!.name).first()).toBeVisible();
|
||||
await expect(page.getByText(vendor!.vendorId!, { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { USERS, login } from "./helpers";
|
||||
|
||||
/**
|
||||
* Issue #96 — sidebar section headings (Purchasing / Crewing / Administration) are
|
||||
* collapsible, collapsed by default, and act as a single-open accordion (opening one
|
||||
* collapses the others).
|
||||
*/
|
||||
test("#96 sidebar sections are collapsible and single-open", async ({ page }) => {
|
||||
await login(page, USERS.MANAGER);
|
||||
// /dashboard is not inside any section, so all sections start collapsed.
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const purchasing = page.getByRole("button", { name: "Purchasing" });
|
||||
const crewing = page.getByRole("button", { name: "Crewing" });
|
||||
await expect(purchasing).toBeVisible();
|
||||
await expect(crewing).toBeVisible();
|
||||
|
||||
// Collapsed by default.
|
||||
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(crewing).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// Opening Purchasing expands it.
|
||||
await purchasing.click();
|
||||
await expect(purchasing).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Opening Crewing collapses Purchasing (single-open accordion).
|
||||
await crewing.click();
|
||||
await expect(crewing).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { resolvePagination } from "@/lib/pagination";
|
||||
|
||||
const OPTIONS = [25, 50, 100];
|
||||
const DEFAULT = 25;
|
||||
|
||||
function resolve(perPageParam: string | number | undefined, pageParam: string | number | undefined, total: number) {
|
||||
return resolvePagination({ perPageParam, pageParam, total, options: OPTIONS, defaultPerPage: DEFAULT });
|
||||
}
|
||||
|
||||
describe("resolvePagination", () => {
|
||||
it("defaults perPage and page when params are missing", () => {
|
||||
expect(resolve(undefined, undefined, 200)).toEqual({
|
||||
perPage: 25,
|
||||
page: 1,
|
||||
totalPages: 8,
|
||||
skip: 0,
|
||||
take: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts allowed page sizes", () => {
|
||||
expect(resolve("50", "1", 200).perPage).toBe(50);
|
||||
expect(resolve("100", "1", 200).perPage).toBe(100);
|
||||
expect(resolve(50, "1", 200).perPage).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to the default for disallowed or non-numeric page sizes", () => {
|
||||
expect(resolve("10", "1", 200).perPage).toBe(25);
|
||||
expect(resolve("999", "1", 200).perPage).toBe(25);
|
||||
expect(resolve("abc", "1", 200).perPage).toBe(25);
|
||||
expect(resolve("0", "1", 200).perPage).toBe(25);
|
||||
});
|
||||
|
||||
it("computes skip/take for a middle page", () => {
|
||||
const p = resolve("25", "3", 200);
|
||||
expect(p).toMatchObject({ page: 3, skip: 50, take: 25, totalPages: 8 });
|
||||
});
|
||||
|
||||
it("clamps a page beyond the last page to the last page", () => {
|
||||
expect(resolve("25", "99", 200)).toMatchObject({ page: 8, totalPages: 8, skip: 175 });
|
||||
});
|
||||
|
||||
it("clamps non-positive / non-numeric page to 1", () => {
|
||||
expect(resolve("25", "0", 200).page).toBe(1);
|
||||
expect(resolve("25", "-5", 200).page).toBe(1);
|
||||
expect(resolve("25", "abc", 200).page).toBe(1);
|
||||
expect(resolve("25", undefined, 200).page).toBe(1);
|
||||
});
|
||||
|
||||
it("floors fractional page numbers", () => {
|
||||
expect(resolve("25", "2.9", 200).page).toBe(2);
|
||||
});
|
||||
|
||||
it("always yields at least one page, even with zero rows", () => {
|
||||
expect(resolve("25", "1", 0)).toMatchObject({ page: 1, totalPages: 1, skip: 0 });
|
||||
});
|
||||
|
||||
it("handles a partial final page", () => {
|
||||
// 23 rows, 25 per page -> single page holding all rows
|
||||
expect(resolve("25", "1", 23)).toMatchObject({ page: 1, totalPages: 1, take: 25 });
|
||||
// 60 rows, 25 per page -> 3 pages, last page holds 10
|
||||
expect(resolve("25", "3", 60)).toMatchObject({ page: 3, totalPages: 3, skip: 50 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
|
||||
describe("isPdfExportServiceRequest", () => {
|
||||
it("allows the export route when the svc token matches", () => {
|
||||
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export", TOKEN, TOKEN)).toBe(true);
|
||||
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export/", TOKEN, TOKEN)).toBe(true); // trailing slash
|
||||
});
|
||||
|
||||
it("denies when the token is missing, empty, or wrong", () => {
|
||||
expect(isPdfExportServiceRequest("/api/po/x/export", TOKEN, undefined)).toBe(false); // service not configured
|
||||
expect(isPdfExportServiceRequest("/api/po/x/export", null, TOKEN)).toBe(false); // no svc on request
|
||||
expect(isPdfExportServiceRequest("/api/po/x/export", "", TOKEN)).toBe(false);
|
||||
expect(isPdfExportServiceRequest("/api/po/x/export", "wrong", TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("only matches the PO export route, not other paths", () => {
|
||||
expect(isPdfExportServiceRequest("/api/po/x/export/extra", TOKEN, TOKEN)).toBe(false);
|
||||
expect(isPdfExportServiceRequest("/api/po/x", TOKEN, TOKEN)).toBe(false);
|
||||
expect(isPdfExportServiceRequest("/dashboard", TOKEN, TOKEN)).toBe(false);
|
||||
expect(isPdfExportServiceRequest("/api/reports/spend", TOKEN, TOKEN)).toBe(false);
|
||||
expect(isPdfExportServiceRequest("/api/po//export", TOKEN, TOKEN)).toBe(false); // empty id
|
||||
});
|
||||
});
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
fyStartYear,
|
||||
fyLabel,
|
||||
fyMonthIndex,
|
||||
weekOfMonth,
|
||||
buildAccountIndex,
|
||||
costCentreRows,
|
||||
costCentreWeekly,
|
||||
accountNodeSpend,
|
||||
accountNodeWeekly,
|
||||
accountLevelRows,
|
||||
topAccountsForCostCentre,
|
||||
costCentresForAccount,
|
||||
childBreakdown,
|
||||
applyScope,
|
||||
parseScope,
|
||||
parseGranularity,
|
||||
resolveFy,
|
||||
resolveMonth,
|
||||
parseSel,
|
||||
toggleSel,
|
||||
allocatePoSpend,
|
||||
type ReportDataset,
|
||||
type AccountNode,
|
||||
} from "@/lib/reports";
|
||||
|
||||
const ACCOUNTS: AccountNode[] = [
|
||||
{ id: "H", code: "5000", name: "Operating", parentId: null, tier: "Heading" },
|
||||
{ id: "S", code: "5100", name: "Vessel Running", parentId: "H", tier: "Sub-heading" },
|
||||
{ id: "L1", code: "5110", name: "Fuel", parentId: "S", tier: "Leaf" },
|
||||
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
|
||||
];
|
||||
|
||||
// fys ascending: [2024, 2025]. PO p1 is multi-account (rows on L1 + L2).
|
||||
const DS: ReportDataset = {
|
||||
vessels: [
|
||||
{ id: "v1", code: "V1", name: "MV One" },
|
||||
{ id: "v2", code: "V2", name: "MV Two" },
|
||||
],
|
||||
accounts: ACCOUNTS,
|
||||
fys: [2024, 2025],
|
||||
rows: [
|
||||
{ poId: "p1", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 },
|
||||
{ poId: "p2", vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1, week: 1 },
|
||||
{ poId: "p1", vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0, week: 0 },
|
||||
{ poId: "p3", vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5, week: 0 },
|
||||
{ poId: "p4", vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11, week: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
describe("financial-year helpers", () => {
|
||||
it("maps Apr–Mar to the Indian FY start year", () => {
|
||||
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
|
||||
expect(fyStartYear(new Date(2025, 0, 15))).toBe(2024); // Jan → prior FY
|
||||
expect(fyStartYear(new Date(2025, 2, 31))).toBe(2024); // Mar → prior FY
|
||||
});
|
||||
it("labels and indexes months within the FY", () => {
|
||||
expect(fyLabel(2025)).toBe("FY 2025–26");
|
||||
expect(fyMonthIndex(new Date(2025, 3, 1))).toBe(0); // Apr
|
||||
expect(fyMonthIndex(new Date(2026, 2, 1))).toBe(11); // Mar
|
||||
});
|
||||
});
|
||||
|
||||
describe("costCentreRows", () => {
|
||||
it("totals the selected FY by vessel with a 12-month series and PO count", () => {
|
||||
const rows = costCentreRows(DS, 2025);
|
||||
const v1 = rows.find((r) => r.id === "v1")!;
|
||||
expect(v1.total).toBe(180);
|
||||
expect(v1.months[0]).toBe(130); // 100 + 30
|
||||
expect(v1.months[1]).toBe(50);
|
||||
expect(v1.poCount).toBe(2); // distinct POs (p1 is multi-account, + p2) — not row count
|
||||
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
|
||||
|
||||
const v2 = rows.find((r) => r.id === "v2")!;
|
||||
expect(v2.total).toBe(70);
|
||||
expect(v2.fyTotals).toEqual([200, 70]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accounting-code rollup", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("rolls leaf spend up to the heading", () => {
|
||||
expect(accountNodeSpend(DS, idx, "H", 2025).total).toBe(250); // 100+50+30+70
|
||||
expect(accountNodeSpend(DS, idx, "L1", 2025).total).toBe(150);
|
||||
});
|
||||
it("lists the children to compare at a drill level", () => {
|
||||
const top = accountLevelRows(DS, idx, null, 2025); // headings
|
||||
expect(top.map((r) => r.node.id)).toEqual(["H"]);
|
||||
const subs = accountLevelRows(DS, idx, "H", 2025);
|
||||
expect(subs.map((r) => r.node.id)).toEqual(["S"]);
|
||||
});
|
||||
it("leaf detection and leaf set", () => {
|
||||
expect(idx.isLeaf("L1")).toBe(true);
|
||||
expect(idx.isLeaf("H")).toBe(false);
|
||||
expect([...idx.leavesUnder("H")].sort()).toEqual(["L1", "L2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakdowns", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("top accounting codes for a cost centre (by tier)", () => {
|
||||
const bd = topAccountsForCostCentre(DS, idx, "v1", 2025, "Leaf");
|
||||
expect(bd.map((b) => [b.id, b.value])).toEqual([
|
||||
["L1", 150],
|
||||
["L2", 30],
|
||||
]);
|
||||
});
|
||||
it("cost centres for an account node", () => {
|
||||
const bd = costCentresForAccount(DS, idx, "H", 2025);
|
||||
expect(bd.map((b) => [b.id, b.value])).toEqual([
|
||||
["v1", 180],
|
||||
["v2", 70],
|
||||
]);
|
||||
});
|
||||
it("child breakdown of a non-leaf node", () => {
|
||||
const bd = childBreakdown(DS, idx, "H", 2025);
|
||||
expect(bd).toEqual([{ id: "S", label: "5100 · Vessel Running", value: 250 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line-item account allocation (#3)", () => {
|
||||
const po = { id: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 };
|
||||
it("splits a PO proportionally across its line-item accounts, summing back to the PO total", () => {
|
||||
const out = allocatePoSpend(po, [
|
||||
{ accountId: "L1", amount: 30 },
|
||||
{ accountId: "L2", amount: 90 },
|
||||
]);
|
||||
const byAcc = Object.fromEntries(out.map((r) => [r.accountId, r.amount]));
|
||||
expect(byAcc["L1"]).toBeCloseTo(25); // 100 * 30/120
|
||||
expect(byAcc["L2"]).toBeCloseTo(75); // 100 * 90/120
|
||||
expect(out.reduce((s, r) => s + r.amount, 0)).toBeCloseTo(100);
|
||||
expect(out.every((r) => r.poId === "po9" && r.vesselId === "v1")).toBe(true);
|
||||
});
|
||||
it("falls a line with no account back to the PO-level account", () => {
|
||||
const out = allocatePoSpend(po, [{ accountId: null, amount: 10 }, { accountId: "L2", amount: 10 }]);
|
||||
expect(Object.fromEntries(out.map((r) => [r.accountId, r.amount]))).toEqual({ L1: 50, L2: 50 });
|
||||
});
|
||||
it("puts the whole amount on the PO account when there are no line items", () => {
|
||||
expect(allocatePoSpend(po, [])).toEqual([{ poId: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekly buckets (#1)", () => {
|
||||
const idx = buildAccountIndex(ACCOUNTS);
|
||||
it("computes week-of-month from the day", () => {
|
||||
expect(weekOfMonth(new Date(2025, 3, 1))).toBe(0);
|
||||
expect(weekOfMonth(new Date(2025, 3, 8))).toBe(1);
|
||||
expect(weekOfMonth(new Date(2025, 3, 29))).toBe(4);
|
||||
});
|
||||
it("buckets a month's spend into weeks for a cost centre and an account node", () => {
|
||||
expect(costCentreWeekly(DS, "v1", 2025, 0)).toEqual([130, 0, 0, 0, 0]); // both month-0 rows in W1
|
||||
expect(accountNodeWeekly(DS, idx, "H", 2025, 0)).toEqual([130, 0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom selection (#2)", () => {
|
||||
it("parses and de-dupes ?sel=", () => {
|
||||
expect(parseSel("a,b,a, ,c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseSel(undefined)).toEqual([]);
|
||||
});
|
||||
it("toggles ids in and out", () => {
|
||||
expect(toggleSel(["a", "b"], "a")).toEqual(["b"]);
|
||||
expect(toggleSel(["a"], "b")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope + param parsing", () => {
|
||||
it("applies Top/Bottom-N to a descending list", () => {
|
||||
const sorted = [5, 4, 3, 2, 1];
|
||||
expect(applyScope(sorted, "top5")).toEqual([5, 4, 3, 2, 1]);
|
||||
expect(applyScope([9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1], "top10")).toHaveLength(10);
|
||||
expect(applyScope(sorted, "bottom5")).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(applyScope(sorted, "all")).toEqual(sorted);
|
||||
});
|
||||
it("parses scope + resolves FY with sensible defaults", () => {
|
||||
expect(parseScope("top10")).toBe("top10");
|
||||
expect(parseScope("garbage")).toBe("top5");
|
||||
expect(resolveFy(DS, "2024")).toBe(2024);
|
||||
expect(resolveFy(DS, undefined)).toBe(2025); // latest
|
||||
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
|
||||
});
|
||||
it("parses granularity (incl. weekly) and resolves the weekly month", () => {
|
||||
expect(parseGranularity("weekly")).toBe("weekly");
|
||||
expect(parseGranularity("yearly")).toBe("yearly");
|
||||
expect(parseGranularity(undefined)).toBe("monthly");
|
||||
expect(resolveMonth(DS, 2025, undefined)).toBe(11); // latest month with spend in FY2025
|
||||
expect(resolveMonth(DS, 2025, "3")).toBe(3);
|
||||
expect(resolveMonth(DS, 2025, "99")).toBe(11); // out of range → latest
|
||||
});
|
||||
});
|
||||
|
|
@ -100,64 +100,3 @@ describe("Sidebar collapsible sections", () => {
|
|||
expect(within(adminVendors).queryByText("Vendors")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Purchase Order links under Purchasing", () => {
|
||||
it("renders the renamed PO links inside the Purchasing section (not top-level)", () => {
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
|
||||
// Collapsed by default → PO links are not in the DOM until Purchasing opens.
|
||||
expect(screen.queryByRole("link", { name: /New Purchase Order/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(headerButton("Purchasing"));
|
||||
|
||||
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Closed Purchase Orders/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Import Purchase Order/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Purchase Order History/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-expands Purchasing when a PO route is active", () => {
|
||||
mockPathname = "/po/new";
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
|
||||
expect(headerButton("Purchasing")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the old PO labels", () => {
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
fireEvent.click(headerButton("Purchasing"));
|
||||
|
||||
// Old labels were "New PO" / "Import PO" / "History".
|
||||
expect(screen.queryByRole("link", { name: /^New PO$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /^Import PO$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /^History$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reports section (Purchasing subheading)", () => {
|
||||
it("reveals the report links under a Purchasing subheading for an analytics role", () => {
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
// Collapsed by default.
|
||||
expect(screen.queryByRole("link", { name: /Accounting Codes/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(headerButton("Reports"));
|
||||
|
||||
expect(screen.getByRole("link", { name: /Cost Centres/i })).toHaveAttribute("href", "/reports/cost-centres");
|
||||
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toHaveAttribute("href", "/reports/accounting-codes");
|
||||
// The "Purchasing" subheading is rendered in addition to the Purchasing section header.
|
||||
expect(screen.getAllByText("Purchasing").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("auto-expands Reports when a report route is active", () => {
|
||||
mockPathname = "/reports/accounting-codes";
|
||||
render(<Sidebar userRole="MANAGER" />);
|
||||
expect(headerButton("Reports")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden from roles without view_analytics", () => {
|
||||
render(<Sidebar userRole="TECHNICAL" />);
|
||||
expect(screen.queryByRole("button", { name: /^Reports/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
|
||||
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
|
||||
|
||||
// Each test adds a real <a> to the document so the capture-phase click
|
||||
// interceptor has something to catch.
|
||||
const links: HTMLAnchorElement[] = [];
|
||||
function addLink(href: string) {
|
||||
const a = document.createElement("a");
|
||||
a.setAttribute("href", href);
|
||||
a.textContent = "go";
|
||||
document.body.appendChild(a);
|
||||
links.push(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
beforeEach(() => pushMock.mockClear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
links.splice(0).forEach((a) => a.remove());
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("UnsavedChangesGuard", () => {
|
||||
it("does not intercept navigation when there are no unsaved changes", () => {
|
||||
render(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
||||
const link = addLink("/catalogue/vendors");
|
||||
const notCanceled = fireEvent.click(link);
|
||||
expect(notCanceled).toBe(true); // default not prevented → navigation proceeds
|
||||
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("intercepts an internal link click and opens the prompt when dirty", () => {
|
||||
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||
const link = addLink("/catalogue/vendors");
|
||||
const notCanceled = fireEvent.click(link);
|
||||
expect(notCanceled).toBe(false); // navigation blocked
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save as draft" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Discard changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stay on page" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not intercept external links (left to the browser's own prompt)", () => {
|
||||
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||
const link = addLink("https://example.com/elsewhere");
|
||||
const notCanceled = fireEvent.click(link);
|
||||
expect(notCanceled).toBe(true);
|
||||
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'Stay on page' closes the prompt without navigating", () => {
|
||||
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||
fireEvent.click(addLink("/dashboard"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stay on page" }));
|
||||
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'Discard changes' navigates to the intended destination", () => {
|
||||
render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||
fireEvent.click(addLink("/catalogue/vendors"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/catalogue/vendors");
|
||||
});
|
||||
|
||||
it("'Save as draft' runs the save handler and closes the prompt", () => {
|
||||
const onSaveDraft = vi.fn();
|
||||
render(<UnsavedChangesGuard enabled onSaveDraft={onSaveDraft} saving={false} />);
|
||||
fireEvent.click(addLink("/dashboard"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save as draft" }));
|
||||
expect(onSaveDraft).toHaveBeenCalledTimes(1);
|
||||
expect(pushMock).not.toHaveBeenCalled(); // the save action does its own redirect
|
||||
expect(screen.queryByText("Unsaved changes")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("arms the browser beforeunload prompt only while dirty", () => {
|
||||
const { rerender } = render(<UnsavedChangesGuard enabled onSaveDraft={noop} saving={false} />);
|
||||
const dirtyEvt = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(dirtyEvt);
|
||||
expect(dirtyEvt.defaultPrevented).toBe(true);
|
||||
|
||||
rerender(<UnsavedChangesGuard enabled={false} onSaveDraft={noop} saving={false} />);
|
||||
const cleanEvt = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(cleanEvt);
|
||||
expect(cleanEvt.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
// The form imports server actions; stub them so the client component renders in jsdom.
|
||||
vi.mock("@/app/(portal)/admin/vendors/actions", () => ({
|
||||
createVendor: vi.fn(),
|
||||
updateVendor: vi.fn(),
|
||||
toggleVendorActive: vi.fn(),
|
||||
}));
|
||||
|
||||
const GSTIN = "27AAHCP5787B1Z6"; // 15 chars
|
||||
|
||||
describe("VendorForm — GSTIN CAPTCHA popup (issue #114)", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openFormAndLookup() {
|
||||
render(<AddVendorButton simple />);
|
||||
fireEvent.click(screen.getByText("+ Add Vendor"));
|
||||
const gstinInput = screen.getByPlaceholderText(/27AAHCP5787B1Z6/);
|
||||
fireEvent.change(gstinInput, { target: { value: GSTIN } });
|
||||
fireEvent.click(screen.getByText("Look up"));
|
||||
// Popup renders the CAPTCHA prompt once the fetch resolves.
|
||||
await screen.findByText(/Enter the code shown in the image/i);
|
||||
}
|
||||
|
||||
it("opens the CAPTCHA in a popup with a Cancel/Close control, leaving the form footer reachable", async () => {
|
||||
await openFormAndLookup();
|
||||
// The CAPTCHA lives in its own popup …
|
||||
expect(screen.getByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeTruthy();
|
||||
// Both the form's ✕ and the popup's ✕/Cancel close controls are present.
|
||||
expect(screen.getAllByLabelText("Close").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("Cancel").length).toBeGreaterThanOrEqual(2);
|
||||
// … and the underlying vendor form's submit button is still rendered (never displaced).
|
||||
expect(screen.getByText("Create Vendor")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the popup on Cancel without closing the vendor form", async () => {
|
||||
await openFormAndLookup();
|
||||
// The popup's Cancel is the first one in the DOM (the CAPTCHA section precedes the footer).
|
||||
fireEvent.click(screen.getAllByText("Cancel")[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
|
||||
});
|
||||
// The vendor form itself stays open.
|
||||
expect(screen.getByText("Create Vendor")).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("verifies the CAPTCHA, fills the form fields, and closes the popup on success", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
||||
if (String(url).includes("/api/gst/captcha")) {
|
||||
return new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
legalName: "Acme Pvt Ltd",
|
||||
tradeName: "Acme",
|
||||
address: "1 Dock Rd",
|
||||
pincode: "400001",
|
||||
gstin: GSTIN,
|
||||
status: "Active",
|
||||
registrationDate: "2020-01-01",
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
await openFormAndLookup();
|
||||
fireEvent.change(screen.getByPlaceholderText("6 digits"), { target: { value: "123456" } });
|
||||
fireEvent.click(screen.getByText("Verify"));
|
||||
|
||||
// Popup closes; success line + populated fields appear on the form.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
|
||||
});
|
||||
expect((screen.getByDisplayValue("Acme") as HTMLInputElement)).toBeTruthy();
|
||||
expect(screen.getByText(/Acme Pvt Ltd — Active/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { filterVendors, vendorLabel, VendorSelect, type VendorOption } from "@/components/ui/vendor-select";
|
||||
|
||||
const VENDORS: VendorOption[] = [
|
||||
{ id: "1", name: "Acme Marine Supplies", vendorId: "V-1001" },
|
||||
{ id: "2", name: "Bluewater Engineering", vendorId: "V-2002" },
|
||||
{ id: "3", name: "Coastal Spares", vendorId: null }, // unverified — no code
|
||||
{ id: "4", name: "Delta Pumps", vendorId: "ACME-99" },
|
||||
];
|
||||
|
||||
// ── Pure filter logic ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("filterVendors", () => {
|
||||
it("returns all vendors for an empty query", () => {
|
||||
expect(filterVendors(VENDORS, "")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("returns all vendors for a whitespace-only query", () => {
|
||||
expect(filterVendors(VENDORS, " ")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("matches by name (case-insensitive)", () => {
|
||||
const res = filterVendors(VENDORS, "bluewater");
|
||||
expect(res.map((v) => v.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("matches by code (vendorId), case-insensitive", () => {
|
||||
const res = filterVendors(VENDORS, "v-2002");
|
||||
expect(res.map((v) => v.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("matches on a code substring even when the name does not contain it", () => {
|
||||
// "acme" appears in vendor #1's name AND in vendor #4's code (ACME-99)
|
||||
const res = filterVendors(VENDORS, "acme");
|
||||
expect(res.map((v) => v.id).sort()).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
it("finds an unverified vendor (null code) by name only", () => {
|
||||
expect(filterVendors(VENDORS, "coastal").map((v) => v.id)).toEqual(["3"]);
|
||||
// ...and never crashes on the null code
|
||||
expect(filterVendors(VENDORS, "9999")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns no matches for an unrelated query", () => {
|
||||
expect(filterVendors(VENDORS, "zzz")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vendorLabel", () => {
|
||||
it("formats a verified vendor as name (CODE)", () => {
|
||||
expect(vendorLabel(VENDORS[0])).toBe("Acme Marine Supplies (V-1001)");
|
||||
});
|
||||
it("formats an unverified vendor as name (unverified)", () => {
|
||||
expect(vendorLabel(VENDORS[2])).toBe("Coastal Spares (unverified)");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Component behaviour ───────────────────────────────────────────────────────
|
||||
|
||||
describe("VendorSelect", () => {
|
||||
it("posts a hidden vendorId input with the empty default", () => {
|
||||
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} />);
|
||||
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
||||
expect(hidden).toBeTruthy();
|
||||
expect(hidden.value).toBe("");
|
||||
});
|
||||
|
||||
it("honours initialValue", () => {
|
||||
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} initialValue="2" />);
|
||||
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe("2");
|
||||
// Trigger shows the formatted label
|
||||
expect(screen.getByText("Bluewater Engineering (V-2002)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens and filters the list by typed query, then selects a vendor", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(<VendorSelect name="vendorId" vendors={VENDORS} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
const search = screen.getByPlaceholderText("Search by name or code…");
|
||||
fireEvent.change(search, { target: { value: "v-2002" } });
|
||||
|
||||
// Only the matching vendor option is in the list
|
||||
expect(screen.getByText("Bluewater Engineering")).toBeTruthy();
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
|
||||
fireEvent.mouseDown(screen.getByText("Bluewater Engineering"));
|
||||
expect(onChange).toHaveBeenCalledWith("2");
|
||||
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe("2");
|
||||
});
|
||||
|
||||
it("keeps 'No vendor selected' selectable to clear the choice", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<VendorSelect name="vendorId" vendors={VENDORS} initialValue="1" onChange={onChange} />
|
||||
);
|
||||
// The trigger shows the current selection's label; click it to open.
|
||||
fireEvent.click(screen.getByText("Acme Marine Supplies (V-1001)"));
|
||||
// The empty option is always present, even before typing
|
||||
fireEvent.mouseDown(screen.getByText("No vendor selected"));
|
||||
expect(onChange).toHaveBeenLastCalledWith("");
|
||||
const hidden = container.querySelector('input[name="vendorId"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe("");
|
||||
});
|
||||
|
||||
it("shows an empty-state message when nothing matches", () => {
|
||||
render(<VendorSelect name="vendorId" vendors={VENDORS} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search by name or code…"), {
|
||||
target: { value: "zzz" },
|
||||
});
|
||||
expect(screen.getByText(/No vendors match/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VendorsTable } from "@/app/(portal)/catalogue/vendors/vendors-table";
|
||||
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
|
|
|
|||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -4,14 +4,6 @@
|
|||
|
||||
### Added
|
||||
|
||||
- **Reports — Purchasing spend analytics** (`view_analytics`: Manager / SuperUser / Auditor / Admin) — `/reports/cost-centres` and `/reports/accounting-codes`, each an index → drill-down → detail. KPI tiles, comparison + trend charts (one colour per item), Top-N tables, per-row sparklines, and CSV export; URL-driven filters (granularity Weekly / Monthly / Yearly, financial year, Top/Bottom-N, an "Add to graph" custom comparison). Spend = post-approval POs by `approvedAt`/`totalAmount`, allocated across each PO's line-item accounting codes. Pure, unit-tested core in `lib/reports.ts`.
|
||||
- **Email PO to vendor** (issue #14) — one-click Outlook draft to the vendor's primary contact with a **7-day download link** to the PO PDF. Rendered by the new **PdfService** microservice (Express + Playwright → headless Chromium) and stored in R2; the PDF is **cached per PO**, so repeat sends reuse the copy and only refresh the link.
|
||||
- **Microservices** — `EpfoService` (UAN / EPFO assisted-lookup proxy; live portal nav stubbed behind `EPFO_LIVE`) and `PdfService` (PO → PDF) join `GstService`. All three are **auto-deployed on each release tag** via the root `ecosystem.config.js` + `deploy.yml` (`pm2 startOrReload … --update-env`).
|
||||
- **Unsaved-changes prompt** (issue #18) — leaving the PO create/edit screen with unsaved edits offers **Save as draft / Discard / Stay** (in-app navigation) or the browser's native warning (refresh / close).
|
||||
- **Crew login on hire** (crewing, feature-flagged) — onboarding, direct placement, and admin crew-create accept an explicit **login email + initial password** for management ranks (`Rank.grantsLogin`), creating the `SITE_STAFF` login in one step.
|
||||
- **Delivery Locations** (issue #19) — admin-managed `Company`+address list backing the PO "Place of Delivery" dropdown, gated by `manage_delivery_locations` (Manager / SuperUser / Admin).
|
||||
- **Terms & Conditions catalogue** (issue #11) — admin-managed, user-defined T&C categories + clauses feeding a dynamic PO terms editor; the chosen rows are a JSON snapshot on `PurchaseOrder.terms`.
|
||||
- **Advance payment on approval** (issue #92) — the approving Manager sets how much is paid first; the resolved absolute amount is stored on `PurchaseOrder.suggestedAdvancePayment` and prefills the first Accounts payment.
|
||||
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
|
||||
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
|
||||
- **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
|
||||
|
|
@ -37,6 +29,4 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- **"Email to vendor" never rendered a real PDF** (issue #14) — the auth middleware redirected PdfService's unauthenticated `svc`-token export fetch to `/login` before the route's token check ran, so the bypass never executed. `/api/po/<id>/export` is now allowed through when its `svc` token matches `PDF_SERVICE_TOKEN` (`lib/pdf-export-auth.ts`); everything else stays auth-gated.
|
||||
- **Reports comparison charts all rendered one colour** — `SERIES_COLORS` lived in a `"use client"` module and was imported by the server-component report pages, where a plain value becomes a client-reference proxy (so `SERIES_COLORS[i]` was `undefined` and recharts fell back to its default stroke). Moved the palette to a dependency-free shared module (`lib/report-colors.ts`).
|
||||
- Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README).
|
||||
|
|
|
|||
132
Docs/TESTING.md
132
Docs/TESTING.md
|
|
@ -1,132 +0,0 @@
|
|||
# Testing
|
||||
|
||||
This repo has three test tiers (see `App/CLAUDE.md` → Commands):
|
||||
|
||||
| Tier | Tool | Scope | Command |
|
||||
|---|---|---|---|
|
||||
| Unit | Vitest (jsdom) | pure functions / components | `pnpm test` |
|
||||
| Integration | Vitest (node + real DB) | server actions against a Postgres DB | `pnpm test:integration` |
|
||||
| E2E (local) | Playwright | full UI against a local `pnpm dev` | `pnpm test:e2e` |
|
||||
|
||||
This document covers a fourth, purpose-built tier:
|
||||
|
||||
## Staging closed-issue verification (`App/tests/staging/`)
|
||||
|
||||
A **feature-level** Playwright suite that drives the **running staging instance**
|
||||
(`pm2 ppms-staging`, port 3200 on pms1 — see [`../automation/README.md`](../automation/README.md) → *Staging*)
|
||||
to verify that every closed portal issue is actually fixed on the deployed build.
|
||||
Unlike the local E2E suite it does not start a dev server; it logs in and clicks
|
||||
through the real staging app, exactly as a user would.
|
||||
|
||||
### Why a dedicated tier
|
||||
|
||||
Staging runs against `pelagia_test`, a daily mirror of production. That mirror only
|
||||
contains real `@pelagiamarine.com` users — most are SSO-only and none have a password
|
||||
we know — so the credentials login can't be used for automated testing. To solve this
|
||||
without touching production, the refresh seeds **deterministic test users** (one per
|
||||
role) with known passwords.
|
||||
|
||||
### Test users (seeding)
|
||||
|
||||
[`App/prisma/seed-test-users.ts`](../App/prisma/seed-test-users.ts) idempotently
|
||||
upserts one credential-capable login per role on the throwaway `@pelagia.local`
|
||||
domain (no collision with real accounts):
|
||||
|
||||
| Email | Password | Role |
|
||||
|---|---|---|
|
||||
| `tech@pelagia.local` | `tech1234` | TECHNICAL |
|
||||
| `manning@pelagia.local` | `manning1234` | MANNING |
|
||||
| `accounts@pelagia.local` | `accounts1234` | ACCOUNTS |
|
||||
| `manager@pelagia.local` | `manager1234` | MANAGER |
|
||||
| `superuser@pelagia.local` | `super1234` | SUPERUSER |
|
||||
| `auditor@pelagia.local` | `audit1234` | AUDITOR |
|
||||
| `admin@pelagia.local` | `admin1234` | ADMIN |
|
||||
| `site@pelagia.local` | `site1234` | SITE_STAFF |
|
||||
|
||||
[`automation/refresh-test-db.sh`](../automation/refresh-test-db.sh) runs this seed
|
||||
automatically after every daily refresh of `pelagia_test`, so the logins always exist
|
||||
on staging. To seed manually (e.g. before a one-off run):
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgresql://…/pelagia_test" pnpm tsx prisma/seed-test-users.ts
|
||||
```
|
||||
|
||||
### Running the suite
|
||||
|
||||
From a machine that can reach pms1, open SSH tunnels to the staging app **and** the
|
||||
DB (the suite reads a few fixture ids straight from `pelagia_test` so it stays stable
|
||||
across the daily refresh):
|
||||
|
||||
```bash
|
||||
ssh -N -L 3200:localhost:3200 -L 15432:localhost:5432 shad0w@<pms1>
|
||||
```
|
||||
|
||||
Then, from `App/`:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BASE_URL=http://localhost:3200 \
|
||||
DATABASE_URL="postgresql://pelagia_user:…@localhost:15432/pelagia_test" \
|
||||
pnpm exec playwright test --config playwright.staging.config.ts
|
||||
```
|
||||
|
||||
- `PLAYWRIGHT_BASE_URL` — the staging app (default `http://localhost:3200`).
|
||||
- `DATABASE_URL` — the tunnelled staging DB, used only for read-only fixture lookups
|
||||
(which approved/closed PO to open, expected counts). Every assertion runs against
|
||||
the live UI.
|
||||
|
||||
### What each script verifies (issue → script map)
|
||||
|
||||
One spec file per issue; the filename is the mapping. `SKIP` means the staging data
|
||||
currently has no row to exercise the case (the spec self-skips with a message).
|
||||
|
||||
| Issue | Script | Verifies | Result |
|
||||
|---|---|---|---|
|
||||
| — | `00-smoke.spec.ts` | staging reachable + all seeded users can log in | PASS |
|
||||
| #4 | `issue-04-po-date-field.spec.ts` | optional, back/forward-datable PO Date field on the PO form | PASS |
|
||||
| #5 | `issue-05-approved-date-as-po-date.spec.ts` | approved PO detail shows the approval date as the PO Date | PASS |
|
||||
| #6 | `issue-06-closed-list-filters.spec.ts` | manager sees all CLOSED POs; submitter's Closed view excludes APPROVED | PASS |
|
||||
| #8 | `issue-08-export-includes-description.spec.ts` | exported PO includes the line-item optional description | PASS |
|
||||
| #10 | `issue-10-attachments-grouped.spec.ts` | PO detail groups attachments by type (Submission/Payment/Delivery) | SKIP (no attachment data on staging) |
|
||||
| #11 | `issue-11-terms-catalogue.spec.ts` | admin T&C catalogue page + dynamic PO terms editor | PASS |
|
||||
| #12 | `issue-12-approved-this-month-card.spec.ts` | manager 'Approved This Month' card shows the correct live count (was stuck at 0) | PASS |
|
||||
| #13 | `issue-13-payments-this-month-card.spec.ts` | accounts 'Payments completed this month' card | **KNOWN FAIL — not implemented on staging** |
|
||||
| #14 | `issue-14-email-to-vendor.spec.ts` | 'Email to vendor' button on an approved PO with a vendor email | PASS |
|
||||
| #19 | `issue-19-place-of-delivery-dropdown.spec.ts` | Place of Delivery is a dropdown + admin delivery-locations page | PASS |
|
||||
| #24/#40 | `issue-24-40-logout-tooltip.spec.ts` | logout tooltip reads 'Log out' | **KNOWN FAIL — still 'Sign out' (these were pipeline test issues)** |
|
||||
| #26/#41 | `issue-26-41-total-po-card.spec.ts` | 'Total Purchase Orders' card count correct (#41) + links to history (#26) | PASS |
|
||||
| #31 | `issue-31-history-multi-status.spec.ts` | PO history filter accepts multiple OR-ed statuses | PASS |
|
||||
| #32 | `issue-32-approved-month-clickthrough.spec.ts` | 'Approved This Month' card links to history filtered by approval date | PASS |
|
||||
| #44 | `issue-44-line-item-units.spec.ts` | line-item unit dropdown includes months and year(s) | PASS |
|
||||
| #50 | `issue-50-rupee-compact-format.spec.ts` | approved-spend card uses ₹ with compact L/Cr formatting | PASS |
|
||||
| #53 | `issue-53-cancel-po-modal.spec.ts` | manager Cancel-PO modal with type-'cancel'-to-confirm guard | PASS |
|
||||
| #57 | `issue-57-vendor-search-catalogue.spec.ts` | /catalogue/vendors searchable by vendor id, id shown next to name | PASS |
|
||||
| #96 | `issue-96-sidebar-collapsible.spec.ts` | sidebar sections collapsible, collapsed by default, single-open | PASS |
|
||||
| #104 | `issue-104-history-pagination.spec.ts` | /history items-per-page pagination | PASS |
|
||||
| #109 | `issue-109-new-po-vendor-search.spec.ts` | new-PO vendor field is a searchable combobox (name + code) | PASS |
|
||||
| #75/#76/#79/#81/#83/#86 | `crewing-epics.spec.ts` | each crewing epic's primary surface renders for an authorised role | PASS |
|
||||
|
||||
### Issues not covered by a staging spec (and why)
|
||||
|
||||
| Issue | Reason |
|
||||
|---|---|
|
||||
| #1 | "Add CHANGELOG.md" — pipeline bootstrap; verified by the file existing at the repo root, not a runtime feature. |
|
||||
| #3, #42 | Explicit pipeline / token test issues ("no action needed; close without a fix"). |
|
||||
| #7 | Inventory-on-approval — the inventory surface is gated by `NEXT_PUBLIC_INVENTORY_ENABLED`, which is **false** on staging, so it is not UI-verifiable there. Covered by the `tests/integration` inventory tests. |
|
||||
| #17 | GST CAPTCHA extraction — depends on the live external GST portal (GstService); not deterministically testable. |
|
||||
| #18 | "Prompt to save draft on navigate-away" — a client-side `beforeunload` guard; not reliably driveable in headless Playwright. |
|
||||
| Crewing deep flows (#77 pipeline, #78 onboarding, #80 PPE, #82 appraisal, #85 sign-off) | State-machine flows covered by the existing integration suites (`tests/integration/applications.test.ts`, `onboarding.test.ts`, `appraisal.test.ts`, `signoff.test.ts`, etc.). The staging suite smoke-checks the epic surfaces render. |
|
||||
|
||||
### Findings (verification result)
|
||||
|
||||
Running the suite against staging surfaced **two closed issues that are not actually
|
||||
fixed** on the current build:
|
||||
|
||||
- **#13** — the Accounts dashboard has no "Payments completed this month" card (only
|
||||
"Ready for Payment" and "Payment Queue Value").
|
||||
- **#24 / #40** — the logout control tooltip still reads "Sign out", not "Log out".
|
||||
(Both were pipeline / button-simulation test issues, likely closed without a code
|
||||
change.)
|
||||
|
||||
These are encoded as `test.fail()` specs so the suite stays green while the gaps are
|
||||
recorded; if either is fixed later, its spec flips to passing and flags the
|
||||
annotation for removal.
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# PdfService
|
||||
|
||||
Renders a PPMS purchase order to a real **PDF** for the **"Email PO to vendor"**
|
||||
feature — a standalone **Express + Playwright** microservice, mirroring
|
||||
`GstService` / `EpfoService`.
|
||||
|
||||
The app's `/api/po/:id/export?format=pdf&pdf=1` produces a print-styled HTML page;
|
||||
PdfService loads that URL in **headless Chromium** and prints it to an A4 PDF. The
|
||||
export URL carries a short-lived **`svc` token** so the export route serves the
|
||||
page without a user session (the app's auth middleware allows that one route
|
||||
through when the token matches — see `App/lib/pdf-export-auth.ts`).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Body / Headers | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/health` | — | `{ status, browser }` |
|
||||
| POST | `/pdf` | `{ url }` + header `x-pdf-token` | `application/pdf` (else `401` / `400` / `403` / `502`) |
|
||||
|
||||
## Security
|
||||
|
||||
- **Token** — when `PDF_SERVICE_TOKEN` is set, `/pdf` requires a matching
|
||||
`x-pdf-token` header (the app and PdfService share the secret).
|
||||
- **Origin allow-list (anti-SSRF)** — when `ALLOWED_ORIGIN` is set, PdfService
|
||||
only navigates to URLs whose origin matches it.
|
||||
- Both unset (dev) → checks are skipped.
|
||||
|
||||
## Env
|
||||
|
||||
```
|
||||
PORT=3005
|
||||
PDF_SERVICE_TOKEN= # shared secret with the app (app side: PDF_SERVICE_TOKEN)
|
||||
ALLOWED_ORIGIN= # e.g. http://localhost:3000 (optional)
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev # tsx watch src/index.ts
|
||||
npm run build && npm start # node dist/index.js
|
||||
```
|
||||
|
||||
## App integration
|
||||
|
||||
`App/lib/pdf-service.ts` (`renderPoPdf`) POSTs `{ url }` to `/pdf`. The app gates
|
||||
the feature on `PDF_SERVICE_URL` + `PDF_SERVICE_TOKEN` (`isPdfServiceConfigured()`),
|
||||
uploads the returned PDF to R2 at a **per-PO key** (reused across sends), and
|
||||
returns a `mailto:` with a 7-day presigned link. `APP_INTERNAL_URL` is the base URL
|
||||
PdfService reaches the app at (falls back to `NEXTAUTH_URL`).
|
||||
|
||||
On **pms1** the service is auto-deployed on each release tag via the root
|
||||
`ecosystem.config.js` (pm2 `pdf-service`, port 3005) — see
|
||||
[Deployment and Operations](https://git.pelagiamarine.com/shad0w/pelagia-portal/wiki/Deployment-and-Operations#microservices).
|
||||
|
|
@ -70,7 +70,6 @@ A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the
|
|||
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
||||
| Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` |
|
||||
| Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) |
|
||||
| PR review-comment watcher | `automation/claude-pr-review-watcher.sh` on pms1 | Addresses `claude-review:` comments on Claude-raised PRs. Own cron entry, own clone (`~/pelagia-pr-review`), own config + lock. See below |
|
||||
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
||||
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
|
||||
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
|
||||
|
|
@ -94,72 +93,6 @@ activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also sat
|
|||
The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback;
|
||||
re-enable its task only if pms1 is unavailable, and disable one before enabling the other.
|
||||
|
||||
## PR review-comment watcher
|
||||
|
||||
Where the issue watcher turns *issues* into PRs, the **PR review-comment watcher**
|
||||
([`automation/claude-pr-review-watcher.sh`](claude-pr-review-watcher.sh)) closes the
|
||||
loop on the other side: it addresses **review comments left on the PRs Claude already
|
||||
raised**. This is how you iterate on an automated PR without dropping into an
|
||||
interactive session — leave a comment, Claude pushes a follow-up commit.
|
||||
|
||||
**How to use it (as a reviewer):** on any open Claude-raised PR, leave a comment that
|
||||
starts with the marker **`claude-review:`** — the text after the marker is the
|
||||
instruction. It works in three places:
|
||||
|
||||
- the **PR conversation** (a normal PR comment),
|
||||
- a **review summary** (the overall body of a submitted review),
|
||||
- an **inline / on-file comment** (Claude is given the file, line, and diff hunk).
|
||||
|
||||
Example inline comment on `App/lib/foo.ts`:
|
||||
|
||||
> `claude-review:` this should null-check `order.vendor` before dereferencing it, and add a test for the null case.
|
||||
|
||||
**What the watcher does each run (every 10 min via cron):**
|
||||
|
||||
1. Lists open PRs Claude raised — head branch starts with `prBranchPrefix` (`claude/`)
|
||||
or the PR is labelled `claude-pr`.
|
||||
2. Collects every `claude-review:` comment **from repo collaborators only** (write
|
||||
access; the repo owner is always included). Comments from anyone else, and the
|
||||
bot's own comments, are ignored. This is the safety gate — only trusted users can
|
||||
make Claude push code.
|
||||
3. Skips comments already handled in a previous run (tracked by a hidden
|
||||
`<!-- ppms-review-bot handled: … -->` marker the bot stamps on its acknowledgements,
|
||||
so a 10-minute poll never redoes the same comment).
|
||||
4. Checks out the **PR's own branch** in `~/pelagia-pr-review`, runs headless Claude
|
||||
Code with the collected instructions (+ the same `pelagia_test` / port-3100 test
|
||||
environment the fixer uses), then pushes the new commit(s) to **the same branch** —
|
||||
updating the open PR in place.
|
||||
5. Acknowledges: posts a reply listing what it addressed (with the handled marker) and
|
||||
adds a 🚀 reaction to each handled PR-conversation comment.
|
||||
|
||||
If Claude judges a comment unclear, out of scope, or too risky to do unattended
|
||||
(migrations, payments, permissions), it makes no commit for it and the watcher posts a
|
||||
"produced no change — a human may need to take these" reply. The comments are still
|
||||
marked handled so the poll doesn't loop on them; re-comment with a clearer
|
||||
`claude-review:` instruction to retry.
|
||||
|
||||
**Deploy on pms1** (mirrors the issue watcher):
|
||||
|
||||
```sh
|
||||
# 1. Place the script + config alongside the issue watcher
|
||||
cp automation/claude-pr-review-watcher.sh ~/pr-review-watcher/
|
||||
cp automation/pr-review-watcher.config.example.json ~/pr-review-watcher/pr-review-watcher.config.json
|
||||
# 2. Edit the config: real token (scope write:repository,write:issue), claudeExe = `which claude`
|
||||
# 3. Add a crontab entry, OFFSET from the issue watcher so the two don't run at the same minute:
|
||||
# 5,15,25,35,45,55 * * * * PATH=<nvm bin>:$PATH ~/pr-review-watcher/claude-pr-review-watcher.sh >> ~/pr-review-watcher/logs/cron.log 2>&1
|
||||
```
|
||||
|
||||
- **Token scope:** needs `write:repository` (push to the PR branch) **plus**
|
||||
`write:issue` (post comments + reactions) — one scope more than the issue watcher.
|
||||
- **Own everything:** separate clone (`~/pelagia-pr-review`), config
|
||||
(`pr-review-watcher.config.json`), and lock (`.pr-review-watcher.lock`) so it never
|
||||
races the issue watcher. Logs land in the same `logs/` dir
|
||||
(`pr-review-<date>.log`, per-PR `claude-pr-<n>-*.log`).
|
||||
- Same **auth preflight** as the issue watcher — no-ops until Claude Code is signed in
|
||||
on pms1 (or `ANTHROPIC_API_KEY` is set).
|
||||
- A Windows `.ps1` port is not provided yet (pms1 is the sole worker); port it from
|
||||
`claude-issue-watcher.ps1` only if you need a failover.
|
||||
|
||||
## Test database (for autofix verification)
|
||||
|
||||
So the fix stage can verify against realistic data without touching production:
|
||||
|
|
|
|||
|
|
@ -1,287 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Claude PR-review-comment watcher -- Linux port (runs on pms1 via cron).
|
||||
#
|
||||
# Sibling to claude-issue-watcher.sh. Where that watcher turns *issues* into PRs,
|
||||
# this one addresses *review comments* left on the PRs Claude already raised.
|
||||
#
|
||||
# Per run:
|
||||
# 1. List open PRs that Claude raised (head branch starts with prBranchPrefix,
|
||||
# or labelled `claude-pr`).
|
||||
# 2. On each, collect every comment carrying the marker `claude-review:` --
|
||||
# from the PR conversation, from review summaries, and from inline (on-file)
|
||||
# review comments -- but ONLY from repo collaborators (write access).
|
||||
# 3. Skip comments already handled in a previous run (tracked by a hidden marker
|
||||
# in the bot's acknowledgement comments).
|
||||
# 4. Run headless Claude Code on the PR's own branch with those instructions;
|
||||
# it edits + verifies, the watcher pushes the new commit(s) to the SAME branch
|
||||
# (updating the PR in place), then acknowledges each comment (reply + reaction).
|
||||
#
|
||||
# Config: pr-review-watcher.config.json next to this script (or pass a path as $1).
|
||||
# See automation/README.md > "PR review-comment watcher".
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG="${1:-$SCRIPT_DIR/pr-review-watcher.config.json}"
|
||||
[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy pr-review-watcher.config.example.json and fill in the token)"; exit 1; }
|
||||
|
||||
cfg() { jq -r "$1" "$CONFIG"; }
|
||||
FORGEJO_URL=$(cfg .forgejoUrl)
|
||||
REPO=$(cfg .repo)
|
||||
TOKEN=$(cfg .token)
|
||||
WORKDIR=$(cfg .workDir)
|
||||
BASE_BRANCH=$(cfg .baseBranch)
|
||||
PR_BRANCH_PREFIX=$(cfg '.prBranchPrefix // "claude/"')
|
||||
MARKER=$(cfg '.marker // "claude-review:"')
|
||||
MAX_PRS=$(cfg '.maxPrsPerRun // 1')
|
||||
MAX_COMMENTS=$(cfg '.maxCommentsPerPr // 20')
|
||||
CLAUDE=$(cfg .claudeExe)
|
||||
TURNS=$(cfg '.claudeMaxTurns // 150')
|
||||
API="$FORGEJO_URL/api/v1"
|
||||
|
||||
# Hidden marker the bot stamps on its acknowledgement comments. The "handled:"
|
||||
# line lists every comment key it has addressed, so subsequent runs skip them.
|
||||
HANDLED_TAG='ppms-review-bot handled:'
|
||||
ACK_REACTION='rocket'
|
||||
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/pr-review-$(date +%F).log"
|
||||
log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
# --- single-instance lock (separate from the issue watcher's) ---
|
||||
exec 9>"$SCRIPT_DIR/.pr-review-watcher.lock"
|
||||
if ! flock -n 9; then log "Another PR-review watcher run is active; exiting."; exit 0; fi
|
||||
|
||||
# --- preflight: idle until Claude Code is authenticated on this host ---
|
||||
if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Forgejo API helpers (curl + jq) ---
|
||||
api() { # METHOD PATH [JSON_BODY]
|
||||
local method=$1 path=$2 body=${3:-}
|
||||
if [ -n "$body" ]; then
|
||||
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" --data "$body"
|
||||
else
|
||||
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN"
|
||||
fi
|
||||
}
|
||||
# Soft variant: never aborts the run on a single failed call (e.g. reactions
|
||||
# unsupported on a given comment type). Returns empty + logs instead.
|
||||
api_soft() { api "$@" 2>/dev/null || { log "api_soft: $1 $2 failed (ignored)"; printf ''; }; }
|
||||
|
||||
add_pr_comment() { # NUMBER TEXT
|
||||
api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null
|
||||
}
|
||||
react() { # COMMENT_ID (PR-conversation comments only; best-effort)
|
||||
api_soft POST "/repos/$REPO/issues/comments/$1/reactions" \
|
||||
"$(jq -nc --arg c "$ACK_REACTION" '{content:$c}')" >/dev/null
|
||||
}
|
||||
|
||||
# --- prepare the dedicated work clone ---
|
||||
host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##')
|
||||
owner=${REPO%%/*}
|
||||
CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
|
||||
[ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
|
||||
|
||||
if [ ! -d "$WORKDIR/.git" ]; then
|
||||
log "Cloning $REPO into $WORKDIR"
|
||||
if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi
|
||||
git -C "$WORKDIR" config user.name "Claude (review-bot)"
|
||||
git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com"
|
||||
fi
|
||||
|
||||
# --- authorization set ---
|
||||
# Collaborators = users with write access. The repo owner is always allowed.
|
||||
# (The bot may post as the owner's account, so we never filter by author to spot
|
||||
# the bot's own comments -- its acknowledgements are excluded by the HANDLED_TAG
|
||||
# marker instead, and human acks lack the claude-review: marker anyway.)
|
||||
COLLAB=$(api GET "/repos/$REPO/collaborators?limit=100" \
|
||||
| jq -c --arg owner "$owner" '[.[].login] + [$owner] | unique')
|
||||
log "Authorized commenters: $(printf '%s' "$COLLAB" | jq -r 'join(", ")')"
|
||||
|
||||
# --- find Claude-raised open PRs (head branch under the prefix, or labelled claude-pr) ---
|
||||
prs=$(api GET "/repos/$REPO/pulls?state=open&limit=50" \
|
||||
| jq -c --arg pfx "$PR_BRANCH_PREFIX" \
|
||||
'[ .[] | select((.head.ref | startswith($pfx)) or (((.labels//[])|map(.name))|index("claude-pr"))) ] | sort_by(.number)')
|
||||
# Scan ALL matching PRs (not truncated) -- the per-run cap below limits only how
|
||||
# many PRs Claude actually RUNS on, so comment-less PRs never crowd out newer ones.
|
||||
n_prs=$(printf '%s' "$prs" | jq 'length')
|
||||
log "Found $n_prs Claude-raised open PR(s) to scan for '$MARKER' comments (will run Claude on up to $MAX_PRS with new comments)"
|
||||
|
||||
# Pull the instruction text that follows the marker out of a comment body.
|
||||
instr_of() { # BODY -> text after the first marker occurrence, trimmed
|
||||
jq -rn --arg b "$1" --arg m "$MARKER" \
|
||||
'$b | split($m) | .[1:] | join($m) | gsub("^\\s+|\\s+$";"")'
|
||||
}
|
||||
|
||||
p=0
|
||||
processed=0
|
||||
while [ "$p" -lt "$n_prs" ]; do
|
||||
pr=$(printf '%s' "$prs" | jq -c ".[$p]")
|
||||
p=$((p+1))
|
||||
num=$(printf '%s' "$pr" | jq -r .number)
|
||||
title=$(printf '%s' "$pr" | jq -r .title)
|
||||
branch=$(printf '%s' "$pr" | jq -r .head.ref)
|
||||
log "-- PR #$num ($branch): $title"
|
||||
|
||||
# ---- gather candidate comments from the three sources ----
|
||||
conv=$(api GET "/repos/$REPO/issues/$num/comments?limit=100")
|
||||
reviews=$(api GET "/repos/$REPO/pulls/$num/reviews?limit=100")
|
||||
|
||||
# Keys already addressed in a prior run (scanned from the bot's ack comments).
|
||||
handled=$(printf '%s' "$conv" | jq -c --arg tag "$HANDLED_TAG" \
|
||||
'[ .[].body // "" | select(contains($tag)) | scan("(?:conv|summary|inline):[0-9]+") ] | unique')
|
||||
|
||||
# A candidate must carry the marker, NOT be one of the bot's own ack comments
|
||||
# (those carry HANDLED_TAG), and come from an authorized (collaborator) user.
|
||||
sel='select(.body != null) | select(.body | contains($m))
|
||||
| select(.body | contains($tag) | not)
|
||||
| select(.user.login as $u | ($collab | index($u)))'
|
||||
|
||||
conv_tasks=$(printf '%s' "$conv" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
|
||||
[ .[] | $sel | { key:(\"conv:\"+(.id|tostring)), kind:\"conv\", id:.id, user:.user.login,
|
||||
loc:\"PR conversation\", body:.body } ]")
|
||||
|
||||
summary_tasks=$(printf '%s' "$reviews" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
|
||||
[ .[] | select(.body != \"\") | $sel
|
||||
| { key:(\"summary:\"+(.id|tostring)), kind:\"summary\", id:.id, user:.user.login,
|
||||
loc:\"review summary\", body:.body } ]")
|
||||
|
||||
# Inline (on-file) comments live under each review.
|
||||
inline_tasks='[]'
|
||||
for rid in $(printf '%s' "$reviews" | jq -r '.[].id'); do
|
||||
rc=$(api_soft GET "/repos/$REPO/pulls/$num/reviews/$rid/comments")
|
||||
[ -z "$rc" ] && continue
|
||||
t=$(printf '%s' "$rc" | jq -c --arg m "$MARKER" --arg tag "$HANDLED_TAG" --argjson collab "$COLLAB" "
|
||||
[ .[] | $sel
|
||||
| { key:(\"inline:\"+(.id|tostring)), kind:\"inline\", id:.id, user:.user.login,
|
||||
loc:(\"inline \"+(.path//\"?\")+\":\"+((.line // .original_line // 0)|tostring)),
|
||||
hunk:(.diff_hunk // \"\"), body:.body } ]")
|
||||
inline_tasks=$(jq -nc --argjson a "$inline_tasks" --argjson b "$t" '$a + $b')
|
||||
done
|
||||
|
||||
all=$(jq -nc --argjson a "$conv_tasks" --argjson b "$summary_tasks" --argjson c "$inline_tasks" '$a + $b + $c')
|
||||
fresh=$(printf '%s' "$all" | jq -c --argjson h "$handled" '[ .[] | select(.key as $k | ($h|index($k)) | not) ]')
|
||||
fresh=$(printf '%s' "$fresh" | jq -c ".[:$MAX_COMMENTS]")
|
||||
n=$(printf '%s' "$fresh" | jq 'length')
|
||||
if [ "$n" -eq 0 ]; then log " no new '$MARKER' comments"; continue; fi
|
||||
if [ "$processed" -ge "$MAX_PRS" ]; then
|
||||
log " $n new '$MARKER' comment(s) but per-run cap ($MAX_PRS) reached; deferring PR #$num to next run"
|
||||
continue
|
||||
fi
|
||||
processed=$((processed+1))
|
||||
log " $n new '$MARKER' comment(s) to address (PR $processed/$MAX_PRS this run)"
|
||||
|
||||
# ---- check out the PR branch in the work clone ----
|
||||
git -C "$WORKDIR" fetch origin -q
|
||||
if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$branch" -q 2>>"$LOG_FILE"; then
|
||||
log " checkout of origin/$branch failed; skipping PR #$num"; continue
|
||||
fi
|
||||
git -C "$WORKDIR" clean -fdq
|
||||
|
||||
# ---- build the prompt ----
|
||||
keys=$(printf '%s' "$fresh" | jq -r '[.[].key] | join(" ")')
|
||||
prompt_file=$(mktemp)
|
||||
{
|
||||
printf '%s\n' "You are addressing REVIEW COMMENTS on PR #$num of the Pelagia Portal (PPMS), a Next.js 15"
|
||||
printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first."
|
||||
printf '%s\n' "You are already checked out on the PR branch '$branch'. Inspect what the PR changed with:"
|
||||
printf '%s\n' " git -C . log --oneline origin/$BASE_BRANCH..HEAD && git diff origin/$BASE_BRANCH...HEAD"
|
||||
printf '\n## PR #%s: %s\n\n' "$num" "$title"
|
||||
printf '%s\n\n' "## Review comments to address (each begins with '$MARKER')"
|
||||
i=0
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
item=$(printf '%s' "$fresh" | jq -c ".[$i]")
|
||||
i=$((i+1))
|
||||
u=$(printf '%s' "$item" | jq -r .user)
|
||||
loc=$(printf '%s' "$item" | jq -r .loc)
|
||||
body=$(printf '%s' "$item" | jq -r .body)
|
||||
hunk=$(printf '%s' "$item" | jq -r '.hunk // ""')
|
||||
instr=$(instr_of "$body")
|
||||
printf '### Comment %s -- %s (by %s)\n' "$i" "$loc" "$u"
|
||||
if [ -n "$hunk" ] && [ "$hunk" != "null" ]; then
|
||||
printf 'Code under review:\n```\n%s\n```\n' "$hunk"
|
||||
fi
|
||||
printf 'Instruction: %s\n\n' "$instr"
|
||||
done
|
||||
printf '%s\n' "## Test environment available to you"
|
||||
printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of"
|
||||
printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and"
|
||||
printf '%s\n' " storage is local in this dev mode."
|
||||
printf '%s\n' "- Run integration tests after loading the env:"
|
||||
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
|
||||
printf '%s\n' "- If you need runtime verification you MAY start a dev server ON PORT 3100 ONLY:"
|
||||
printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)"
|
||||
printf '%s\n' " Stop ONLY your own server by port ('fuser -k 3100/tcp'); NEVER a broad 'pkill -f next'."
|
||||
printf '%s\n' ""
|
||||
printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)"
|
||||
printf '%s\n' "1. Make the focused changes the review comments ask for -- nothing more."
|
||||
printf '%s\n' "2. If you change code under App/app|lib|components|hooks, add or update a test (the PR check"
|
||||
printf '%s\n' " rejects code changes with no test change). Model integration tests on"
|
||||
printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts."
|
||||
printf '%s\n' "3. Verify: 'cd App && pnpm type-check' (no new errors); run relevant tests."
|
||||
printf '%s\n' "4. Update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/, CHANGELOG.md)."
|
||||
printf '%s\n' "5. Commit ALL changes to the current branch with a conventional message referencing #$num."
|
||||
printf '%s\n' "6. Do NOT push, do NOT switch branches, do NOT open/close PRs. The supervisor pushes."
|
||||
printf '%s\n' "If a comment is unclear, out of scope, or too risky to do unattended (migrations, payments,"
|
||||
printf '%s\n' "permissions), make NO commit for it and explain why in CLAUDE_RESULT.md in the repo root."
|
||||
} > "$prompt_file"
|
||||
|
||||
clog="$LOG_DIR/claude-pr-$num-$(date +%Y%m%d-%H%M%S).log"
|
||||
log " Running Claude on PR #$num (log: $clog)"
|
||||
( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \
|
||||
--max-turns "$TURNS" --output-format text < "$prompt_file" > "$clog" 2>&1 ); rc=$?
|
||||
log " Claude exited with code $rc for PR #$num"
|
||||
rm -f "$prompt_file"
|
||||
|
||||
note=""
|
||||
if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then
|
||||
note=$(cat "$WORKDIR/CLAUDE_RESULT.md")
|
||||
rm -f "$WORKDIR/CLAUDE_RESULT.md"
|
||||
git -C "$WORKDIR" checkout -- . 2>/dev/null
|
||||
fi
|
||||
|
||||
# ---- build the acknowledgement (lists handled keys + quotes each comment) ----
|
||||
ack_items=$(printf '%s' "$fresh" | jq -r '.[] | "- **\(.loc)** (by \(.user))"')
|
||||
|
||||
commits=$(git -C "$WORKDIR" rev-list "origin/$branch..HEAD" --count 2>/dev/null || echo 0)
|
||||
if [ "${commits:-0}" -gt 0 ]; then
|
||||
log " Claude made $commits commit(s); pushing to $branch"
|
||||
if ! git -C "$WORKDIR" push -u origin "$branch" -q 2>>"$LOG_FILE"; then
|
||||
log " push failed for PR #$num"
|
||||
add_pr_comment "$num" "[Claude review-bot] Addressed the review comments locally but the push to \`$branch\` failed. See watcher logs on pms1: \`$clog\`.
|
||||
<!-- $HANDLED_TAG -->"
|
||||
continue
|
||||
fi
|
||||
body="[Claude review-bot] Addressed the following review comment(s) on \`$branch\` ($commits commit(s) pushed):
|
||||
|
||||
$ack_items
|
||||
${note:+
|
||||
Notes:
|
||||
$note
|
||||
}
|
||||
<!-- $HANDLED_TAG $keys -->"
|
||||
add_pr_comment "$num" "$body"
|
||||
# Best-effort reaction on PR-conversation comments (reactions API is keyed
|
||||
# to issue comments; inline/summary review comments are tracked by the marker).
|
||||
for cid in $(printf '%s' "$fresh" | jq -r '.[] | select(.kind=="conv") | .id'); do react "$cid"; done
|
||||
log " PR #$num updated and acknowledged"
|
||||
else
|
||||
log " No commits produced for PR #$num"
|
||||
reason=${note:-"Claude did not produce a change. See watcher logs on pms1: \`$clog\`."}
|
||||
add_pr_comment "$num" "[Claude review-bot] Reviewed the marked comment(s) but produced no change:
|
||||
|
||||
$reason
|
||||
|
||||
A human may need to take these:
|
||||
$ack_items
|
||||
<!-- $HANDLED_TAG $keys -->"
|
||||
log " PR #$num: no change, acknowledged (marked handled to avoid re-running)"
|
||||
fi
|
||||
done
|
||||
|
||||
log "PR-review watcher run complete."
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"forgejoUrl": "https://git.pelagiamarine.com",
|
||||
"repo": "shad0w/pelagia-portal",
|
||||
"token": "<forgejo token with write:repository,write:issue>",
|
||||
"workDir": "/home/shad0w/pelagia-pr-review",
|
||||
"baseBranch": "master",
|
||||
"prBranchPrefix": "claude/",
|
||||
"marker": "claude-review:",
|
||||
"maxPrsPerRun": 1,
|
||||
"maxCommentsPerPr": 20,
|
||||
"claudeExe": "/home/shad0w/.nvm/versions/node/<ver>/bin/claude",
|
||||
"claudeMaxTurns": 150
|
||||
}
|
||||
|
|
@ -74,21 +74,3 @@ if [ -n "$MIG_DIR" ]; then
|
|||
else
|
||||
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
|
||||
fi
|
||||
|
||||
# Seed deterministic, credential-capable TEST USERS (one per role) so the staging
|
||||
# instance can be logged into for end-to-end feature verification. The prod mirror
|
||||
# only carries real @pelagiamarine.com users (mostly SSO-only, no known password),
|
||||
# which makes credential login — and therefore the Playwright closed-issue suite
|
||||
# (App/tests/staging/) — impossible. These @pelagia.local accounts never exist in
|
||||
# prod, so there is no collision; the seed is idempotent (upsert by email).
|
||||
# See App/tests/staging/ and Docs/TESTING.md.
|
||||
if [ -n "$MIG_DIR" ] && [ -f "$MIG_DIR/prisma/seed-test-users.ts" ]; then
|
||||
log "Seeding test users into $TEST_DB ..."
|
||||
if ( cd "$MIG_DIR" && DATABASE_URL="$TEST_URL" pnpm tsx prisma/seed-test-users.ts ) >/tmp/seed-test-users.log 2>&1; then
|
||||
log "Test users seeded."
|
||||
else
|
||||
log "WARNING: test-user seed failed; see /tmp/seed-test-users.log"; tail -5 /tmp/seed-test-users.log
|
||||
fi
|
||||
else
|
||||
log "Skipping test-user seed (no checkout with prisma/seed-test-users.ts)."
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue