Compare commits
23 commits
feat/compa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c4c370f6 | |||
| 65a9335de1 | |||
| cb661949d9 | |||
| 610c9aa56d | |||
| 6677ef4fcf | |||
| 4fee393c84 | |||
| 3b9bc0be1b | |||
| 0fdd899096 | |||
| 43d139234e | |||
|
|
cb25d2e5fd | ||
| 9de60200f9 | |||
| a8d772d63b | |||
| a197b966b1 | |||
| 058ba1d12e | |||
| 0b10ba5e54 | |||
| fbdc7b2235 | |||
| 9e787fd15f | |||
| 8ee077e548 | |||
| 991b7ca5dd | |||
| 4c53aeecb0 | |||
| b70eec261b | |||
| d9394e6afb | |||
| 4712fafb4b |
39 changed files with 1069 additions and 69 deletions
|
|
@ -4,6 +4,7 @@ name: PR checks
|
||||||
# - code changes must ship with tests (docs/config/automation are exempt)
|
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||||
# - type-check is clean across the whole project (tests included)
|
# - type-check is clean across the whole project (tests included)
|
||||||
# - unit tests pass
|
# - unit tests pass
|
||||||
|
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||||
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|
@ -56,3 +57,45 @@ jobs:
|
||||||
set -e
|
set -e
|
||||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Integration tests (ephemeral Postgres)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
# Throwaway Postgres per run — isolated from prod / pelagia_test / staging.
|
||||||
|
# A random host port avoids collisions with the host DB and concurrent runs.
|
||||||
|
PG="ci-pg-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
docker rm -f "$PG" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$PG" \
|
||||||
|
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=pelagia_ci \
|
||||||
|
-p 127.0.0.1::5432 postgres:16 >/dev/null
|
||||||
|
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec "$PG" pg_isready -U ci -d pelagia_ci >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
PORT=$(docker inspect --format '{{ (index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort }}' "$PG")
|
||||||
|
export DATABASE_URL="postgresql://ci:ci@127.0.0.1:${PORT}/pelagia_ci"
|
||||||
|
# Non-secret placeholders so auth.ts (reads these at module load) boots in dev mode.
|
||||||
|
export NEXTAUTH_SECRET="ci-secret"
|
||||||
|
export NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
export AZURE_AD_CLIENT_ID="placeholder"
|
||||||
|
export AZURE_AD_CLIENT_SECRET="placeholder"
|
||||||
|
export AZURE_AD_TENANT_ID="placeholder"
|
||||||
|
|
||||||
|
cd App
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate:deploy # apply migrations to the fresh DB
|
||||||
|
pnpm db:seed # dev seed — integration tests rely on it
|
||||||
|
pnpm test:integration # node + real DB — must pass
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
||||||
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
||||||
SUBMITTED: "Submitted", REJECTED: "Rejected",
|
SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const STATUSES = [
|
||||||
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
||||||
{ value: "CLOSED", label: "Closed" },
|
{ value: "CLOSED", label: "Closed" },
|
||||||
{ value: "REJECTED", label: "Rejected" },
|
{ value: "REJECTED", label: "Rejected" },
|
||||||
|
{ value: "CANCELLED", label: "Cancelled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,10 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
{orders.map((po) => (
|
{orders.map((po) => (
|
||||||
<tr key={po.id} className="hover:bg-neutral-50">
|
<tr
|
||||||
|
key={po.id}
|
||||||
|
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
|
||||||
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
||||||
{po.poNumber}
|
{po.poNumber}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export function VendorsTable({
|
||||||
? vendors.filter(
|
? vendors.filter(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.name.toLowerCase().includes(q) ||
|
v.name.toLowerCase().includes(q) ||
|
||||||
|
(v.vendorId && v.vendorId.toLowerCase().includes(q)) ||
|
||||||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
||||||
(v.address && v.address.toLowerCase().includes(q))
|
(v.address && v.address.toLowerCase().includes(q))
|
||||||
)
|
)
|
||||||
|
|
@ -89,7 +90,7 @@ export function VendorsTable({
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search by name, GSTIN or address…"
|
placeholder="Search by name, ID, GSTIN or address…"
|
||||||
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
|
|
@ -151,6 +152,9 @@ export function VendorsTable({
|
||||||
<Link href={`/inventory/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}
|
{vendor.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{vendor.vendorId && (
|
||||||
|
<span className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-500">{vendor.vendorId}</span>
|
||||||
|
)}
|
||||||
{vendor.isVerified && (
|
{vendor.isVerified && (
|
||||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canPerformAction } from "@/lib/po-state-machine";
|
import { canPerformAction, canCancel } from "@/lib/po-state-machine";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
|
@ -113,3 +114,118 @@ export async function discardDraftPo(
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cancel a PO ───────────────────────────────────────────────────────────────
|
||||||
|
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
|
||||||
|
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
|
||||||
|
// explicit whitelists, none of which include CANCELLED).
|
||||||
|
|
||||||
|
export async function cancelPo({
|
||||||
|
poId,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
poId: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||||
|
return { error: "You do not have permission to cancel purchase orders." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = (reason ?? "").trim();
|
||||||
|
if (!trimmed) return { error: "A cancellation reason is required." };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
include: { submitter: true },
|
||||||
|
});
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (!canCancel(po.status, session.user.role)) {
|
||||||
|
return {
|
||||||
|
error: po.status === "CANCELLED"
|
||||||
|
? "This purchase order is already cancelled."
|
||||||
|
: "You cannot cancel this purchase order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
cancellationReason: trimmed,
|
||||||
|
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the submitter and Accounts (they track spend).
|
||||||
|
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||||
|
const recipients = [po.submitter, ...accounts].filter(
|
||||||
|
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
|
||||||
|
);
|
||||||
|
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/history");
|
||||||
|
revalidatePath("/my-orders");
|
||||||
|
revalidatePath("/payments");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
|
||||||
|
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
|
||||||
|
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
|
||||||
|
// surfaced on the replacement via the schema self-relation.
|
||||||
|
|
||||||
|
export async function supersedePo({
|
||||||
|
poId,
|
||||||
|
replacementPoNumber,
|
||||||
|
}: {
|
||||||
|
poId: string;
|
||||||
|
replacementPoNumber: string;
|
||||||
|
}): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||||
|
return { error: "You do not have permission to link a superseding purchase order." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (replacementPoNumber ?? "").trim();
|
||||||
|
if (!num) return { error: "Enter the PO number that supersedes this one." };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (po.status !== "CANCELLED") {
|
||||||
|
return { error: "Only a cancelled purchase order can be superseded." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacement = await db.purchaseOrder.findUnique({
|
||||||
|
where: { poNumber: num },
|
||||||
|
select: { id: true, poNumber: true },
|
||||||
|
});
|
||||||
|
if (!replacement) return { error: `No purchase order found with number "${num}".` };
|
||||||
|
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
supersededById: replacement.id,
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: "SUPERSEDED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
note: `Superseded by ${replacement.poNumber}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
revalidatePath(`/po/${replacement.id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
documents: { orderBy: { uploadedAt: "desc" } },
|
documents: { orderBy: { uploadedAt: "desc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
receipt: true,
|
receipt: true,
|
||||||
|
supersededBy: { select: { id: true, poNumber: true } },
|
||||||
|
supersedes: { select: { id: true, poNumber: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import ExcelJS from "exceljs";
|
import ExcelJS from "exceljs";
|
||||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||||
import { downloadBuffer } from "@/lib/storage";
|
import { downloadBuffer } from "@/lib/storage";
|
||||||
|
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
||||||
|
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||||
|
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||||
|
|
||||||
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
||||||
|
|
||||||
|
|
@ -31,12 +34,15 @@ function mimeForKey(key: string): string {
|
||||||
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download a stored image and return it base64-encoded (or null if missing).
|
interface EmbeddedImage { base64: string; mime: string; width: number; height: number }
|
||||||
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
|
|
||||||
|
// Download a stored image; return base64 + mime + pixel dimensions (or null if missing).
|
||||||
|
async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage | null> {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
const buf = await downloadBuffer(key);
|
const buf = await downloadBuffer(key);
|
||||||
if (!buf) return null;
|
if (!buf) return null;
|
||||||
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
|
const size = getImageSize(buf) ?? { width: 100, height: 100 };
|
||||||
|
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -65,9 +71,11 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
|
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
||||||
|
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
||||||
// The submitter's signature is never embedded; only the approving manager's signature is used.
|
// The submitter's signature is never embedded; only the approving manager's signature is used.
|
||||||
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
|
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
|
||||||
|
const isCancelled = po.status === "CANCELLED";
|
||||||
if (!EXPORTABLE_STATUSES.includes(po.status)) {
|
if (!EXPORTABLE_STATUSES.includes(po.status)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Export is only available for approved purchase orders." },
|
{ error: "Export is only available for approved purchase orders." },
|
||||||
|
|
@ -126,6 +134,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
// Fetch approver's signature for embedding in the document
|
// Fetch approver's signature for embedding in the document
|
||||||
let signatureBase64: string | null = null;
|
let signatureBase64: string | null = null;
|
||||||
let signatureMime = "image/png";
|
let signatureMime = "image/png";
|
||||||
|
let signatureSize: { width: number; height: number } | null = null;
|
||||||
if (approvalAction) {
|
if (approvalAction) {
|
||||||
const approver = await db.user.findUnique({
|
const approver = await db.user.findUnique({
|
||||||
where: { id: approvalAction.actorId },
|
where: { id: approvalAction.actorId },
|
||||||
|
|
@ -137,6 +146,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
signatureBase64 = buf.toString("base64");
|
signatureBase64 = buf.toString("base64");
|
||||||
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
|
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
|
||||||
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||||
|
signatureSize = getImageSize(buf) ?? { width: 360, height: 96 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,15 +285,15 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.mergeCells("A4:I4");
|
ws.mergeCells("A4:I4");
|
||||||
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
||||||
|
|
||||||
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
|
// ══ Company logo (floats top-left over the header; aspect preserved) ═════
|
||||||
if (logoImg) {
|
if (logoImg) {
|
||||||
const logoId = wb.addImage({
|
const logoId = wb.addImage({
|
||||||
base64: logoImg.base64,
|
base64: logoImg.base64,
|
||||||
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||||
});
|
});
|
||||||
ws.addImage(logoId, {
|
ws.addImage(logoId, {
|
||||||
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
|
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
|
||||||
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
|
ext: scaleToBox(logoImg, 96, 52),
|
||||||
editAs: "oneCell",
|
editAs: "oneCell",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -450,16 +460,47 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.getRow(SIG_ROW + 1).height = 14;
|
ws.getRow(SIG_ROW + 1).height = 14;
|
||||||
ws.getRow(SIG_ROW + 2).height = 14;
|
ws.getRow(SIG_ROW + 2).height = 14;
|
||||||
|
|
||||||
// Left sig block (approver — the manager who authorized the PO)
|
// Left signatory block (cols A-D). Position images by absolute pixels via native
|
||||||
if (signatureBase64) {
|
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels.
|
||||||
|
const EMU = 9525; // EMU per pixel
|
||||||
|
const COL_PX = [22, 4, 28, 15, 8, 15, 15, 8, 16].map((w) => Math.round(w * 7 + 5));
|
||||||
|
const SIG_BLOCK_PX = COL_PX[0] + COL_PX[1] + COL_PX[2] + COL_PX[3]; // A-D
|
||||||
|
const anchorAt = (leftPx: number, row: number) => {
|
||||||
|
let x = 0;
|
||||||
|
for (let c = 0; c < COL_PX.length - 1; c++) {
|
||||||
|
if (leftPx < x + COL_PX[c]) {
|
||||||
|
return { nativeCol: c, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||||
|
}
|
||||||
|
x += COL_PX[c];
|
||||||
|
}
|
||||||
|
return { nativeCol: COL_PX.length - 1, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null;
|
||||||
|
const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null;
|
||||||
|
// Signature centred over the name; stamp to its RIGHT with a gap (no overlap).
|
||||||
|
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt });
|
||||||
|
|
||||||
|
// Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch.
|
||||||
|
if (stampImg && stampExt && stampLeft != null) {
|
||||||
|
const stampId = wb.addImage({
|
||||||
|
base64: stampImg.base64,
|
||||||
|
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||||
|
});
|
||||||
|
ws.addImage(stampId, {
|
||||||
|
tl: anchorAt(stampLeft, SIG_ROW - 1),
|
||||||
|
ext: stampExt,
|
||||||
|
editAs: "oneCell",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
|
||||||
|
if (signatureBase64 && sigExt && sigLeft != null) {
|
||||||
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
|
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
|
||||||
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
|
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
|
||||||
// Span the image across columns A-D in the sig row
|
|
||||||
ws.addImage(imgId, {
|
ws.addImage(imgId, {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
|
||||||
tl: { col: 0, row: SIG_ROW - 1 } as any,
|
ext: sigExt,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
br: { col: 4, row: SIG_ROW } as any,
|
|
||||||
editAs: "oneCell",
|
editAs: "oneCell",
|
||||||
});
|
});
|
||||||
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
|
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
|
||||||
|
|
@ -478,19 +519,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.getRow(SIG_ROW + 2).height = 14;
|
ws.getRow(SIG_ROW + 2).height = 14;
|
||||||
ws.getRow(SIG_ROW + 3).height = 14;
|
ws.getRow(SIG_ROW + 3).height = 14;
|
||||||
|
|
||||||
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
|
|
||||||
if (stampImg) {
|
|
||||||
const stampId = wb.addImage({
|
|
||||||
base64: stampImg.base64,
|
|
||||||
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
|
||||||
});
|
|
||||||
ws.addImage(stampId, {
|
|
||||||
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
|
|
||||||
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
|
|
||||||
editAs: "oneCell",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right sig block (vendor)
|
// Right sig block (vendor)
|
||||||
const vName = po.vendor?.name ?? "";
|
const vName = po.vendor?.name ?? "";
|
||||||
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
||||||
|
|
@ -508,6 +536,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
||||||
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
||||||
|
|
||||||
|
// ══ Cancelled watermark — diagonal "CANCELLED" centred over the sheet ════
|
||||||
|
// Pixel-sized (aspect preserved) so the text spans the page like the PDF,
|
||||||
|
// rather than being stretched/squished by a cell-range anchor.
|
||||||
|
if (isCancelled) {
|
||||||
|
const wmId = wb.addImage({ base64: CANCELLED_WATERMARK_PNG_BASE64, extension: "png" });
|
||||||
|
const ext = scaleToBox({ width: CANCELLED_WATERMARK_W, height: CANCELLED_WATERMARK_H }, 880, 720);
|
||||||
|
ws.addImage(wmId, {
|
||||||
|
tl: { col: 0.15, row: 5 } as unknown as ExcelJS.Anchor,
|
||||||
|
ext,
|
||||||
|
editAs: "oneCell",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Serialise ─────────────────────────────────────────────────────────
|
// ── Serialise ─────────────────────────────────────────────────────────
|
||||||
const buf = await wb.xlsx.writeBuffer();
|
const buf = await wb.xlsx.writeBuffer();
|
||||||
const slug = po.poNumber.replace(/\//g, "-");
|
const slug = po.poNumber.replace(/\//g, "-");
|
||||||
|
|
@ -665,6 +706,24 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
background: ${BRAND_BAR_COLOR};
|
background: ${BRAND_BAR_COLOR};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Cancelled watermark ── */
|
||||||
|
.cancelled-watermark {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-35deg);
|
||||||
|
font-size: 96pt;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: rgba(200, 0, 0, 0.18);
|
||||||
|
border: 6px solid rgba(200, 0, 0, 0.18);
|
||||||
|
padding: 8px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
body { margin: 8mm 10mm; }
|
body { margin: 8mm 10mm; }
|
||||||
|
|
@ -674,6 +733,8 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
||||||
|
|
||||||
<div class="no-print" style="margin-bottom:8px">
|
<div class="no-print" style="margin-bottom:8px">
|
||||||
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
||||||
🖨 Print / Save as PDF
|
🖨 Print / Save as PDF
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const PO_STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||||
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
|
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
|
||||||
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
|
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
158
App/components/po/cancel-po-controls.tsx
Normal file
158
App/components/po/cancel-po-controls.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||||
|
|
||||||
|
// ── Cancel PO button + confirmation modal ──────────────────────────────────────
|
||||||
|
// The manager must type the word "cancel" and provide a reason before the action
|
||||||
|
// is enabled — a deliberate friction step for an irreversible, terminal action.
|
||||||
|
|
||||||
|
export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const confirmed = confirmText.trim().toLowerCase() === "cancel";
|
||||||
|
const canSubmit = confirmed && reason.trim().length > 0 && !pending;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (pending) return;
|
||||||
|
setOpen(false);
|
||||||
|
setReason("");
|
||||||
|
setConfirmText("");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await cancelPo({ poId, reason: reason.trim() });
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setPending(false);
|
||||||
|
} else {
|
||||||
|
setPending(false);
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel PO
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={close}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900">Cancel {poNumber}?</h2>
|
||||||
|
<p className="mt-1.5 text-sm text-neutral-600">
|
||||||
|
This marks the purchase order as <strong>cancelled</strong> and removes its value from
|
||||||
|
all spend trackers and graphs. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="mt-4 block text-xs font-medium text-neutral-700">
|
||||||
|
Reason for cancellation <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. Duplicate order — superseded by a corrected PO"
|
||||||
|
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-xs font-medium text-neutral-700">
|
||||||
|
Type <span className="font-mono font-semibold">cancel</span> to confirm
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="cancel"
|
||||||
|
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
|
||||||
|
<div className="mt-5 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Keep PO
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Cancelling…" : "Cancel this PO"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supersede: link a cancelled PO to the existing PO that replaces it ──────────
|
||||||
|
|
||||||
|
export function SupersedeForm({ poId }: { poId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleLink(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!value.trim()) return;
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await supersedePo({ poId, replacementPoNumber: value.trim() });
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setPending(false);
|
||||||
|
} else {
|
||||||
|
setPending(false);
|
||||||
|
setValue("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleLink} className="mt-2 flex flex-wrap items-start gap-2">
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="Replacement PO number, e.g. PMS/HNR1/9001/2026-27"
|
||||||
|
className="min-w-[260px] flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending || !value.trim()}
|
||||||
|
className="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? "Linking…" : "Link replacement"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="w-full text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||||
|
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
||||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { groupAttachments } from "@/lib/attachments";
|
import { groupAttachments } from "@/lib/attachments";
|
||||||
|
|
@ -40,6 +41,10 @@ type PoWithRelations = {
|
||||||
approvedAt: Date | null;
|
approvedAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
closedAt: Date | null;
|
closedAt: Date | null;
|
||||||
|
cancelledAt?: Date | null;
|
||||||
|
cancellationReason?: string | null;
|
||||||
|
supersededBy?: { id: string; poNumber: string } | null;
|
||||||
|
supersedes?: { id: string; poNumber: string }[];
|
||||||
submitter: { id: string; name: string; email: string };
|
submitter: { id: string; name: string; email: string };
|
||||||
vessel: { id: string; name: string };
|
vessel: { id: string; name: string };
|
||||||
account: { id: string; name: string; code: string };
|
account: { id: string; name: string; code: string };
|
||||||
|
|
@ -92,6 +97,8 @@ const ACTION_LABELS: Record<string, string> = {
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
MANAGER_LINE_EDIT: "Manager amended line items",
|
MANAGER_LINE_EDIT: "Manager amended line items",
|
||||||
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
|
SUPERSEDED: "Superseded",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||||
|
|
@ -203,8 +210,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<DiscardDraftButton poId={po.id} />
|
<DiscardDraftButton poId={po.id} />
|
||||||
)}
|
)}
|
||||||
{/* Export buttons — only available once the PO has been approved by a manager */}
|
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
|
||||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
|
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
|
||||||
<a
|
<a
|
||||||
href={`/api/po/${po.id}/export?format=pdf`}
|
href={`/api/po/${po.id}/export?format=pdf`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -220,9 +227,59 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
Export XLSX
|
Export XLSX
|
||||||
</a>
|
</a>
|
||||||
</>)}
|
</>)}
|
||||||
|
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
|
||||||
|
{po.status !== "CANCELLED" &&
|
||||||
|
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
||||||
|
!readOnly && (
|
||||||
|
<CancelPoButton poId={po.id} poNumber={po.poNumber} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
|
||||||
|
{po.status === "CANCELLED" && (
|
||||||
|
<div className="rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-danger-700">
|
||||||
|
Cancelled{po.cancelledAt ? ` on ${formatDate(po.cancelledAt)}` : ""}
|
||||||
|
</p>
|
||||||
|
{po.cancellationReason && (
|
||||||
|
<p className="mt-0.5 text-sm text-danger-700">Reason: {po.cancellationReason}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-sm text-danger-700">
|
||||||
|
{po.supersededBy ? (
|
||||||
|
<p>
|
||||||
|
Superseded by{" "}
|
||||||
|
<Link href={`/po/${po.supersededBy.id}`} className="font-mono font-medium underline">
|
||||||
|
{po.supersededBy.poNumber}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : ["MANAGER", "SUPERUSER"].includes(currentRole) && !readOnly ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-danger-700/80">Optionally link the PO that replaces this one:</p>
|
||||||
|
<SupersedeForm poId={po.id} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reciprocal "supersedes" link — shown on the replacement PO */}
|
||||||
|
{po.supersedes && po.supersedes.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3">
|
||||||
|
<p className="text-sm text-neutral-700">
|
||||||
|
Supersedes{" "}
|
||||||
|
{po.supersedes.map((s, i) => (
|
||||||
|
<span key={s.id}>
|
||||||
|
{i > 0 && ", "}
|
||||||
|
<Link href={`/po/${s.id}`} className="font-mono font-medium text-primary-600 underline">
|
||||||
|
{s.poNumber}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Manager note banner */}
|
{/* Manager note banner */}
|
||||||
{po.managerNote && (
|
{po.managerNote && (
|
||||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
|
|
|
||||||
6
App/lib/cancelled-watermark.ts
Normal file
6
App/lib/cancelled-watermark.ts
Normal file
File diff suppressed because one or more lines are too long
46
App/lib/image-size.ts
Normal file
46
App/lib/image-size.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Image dimension helpers used to size XLSX floating images by pixels with the
|
||||||
|
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
|
||||||
|
// an image to fill a cell range, which distorts logos / signatures / stamps.
|
||||||
|
|
||||||
|
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
|
||||||
|
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
|
||||||
|
// PNG — IHDR width/height at byte offsets 16 / 20
|
||||||
|
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
||||||
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
// JPEG — scan segments for a Start-Of-Frame marker
|
||||||
|
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
||||||
|
let o = 2;
|
||||||
|
while (o + 9 < buf.length) {
|
||||||
|
if (buf[o] !== 0xff) { o++; continue; }
|
||||||
|
const m = buf[o + 1];
|
||||||
|
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
||||||
|
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
|
||||||
|
}
|
||||||
|
o += 2 + buf.readUInt16BE(o + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebP — RIFF container, VP8 / VP8L / VP8X
|
||||||
|
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
|
||||||
|
const fmt = buf.toString("ascii", 12, 16);
|
||||||
|
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
||||||
|
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
|
||||||
|
if (fmt === "VP8X") {
|
||||||
|
return {
|
||||||
|
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
|
||||||
|
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
|
||||||
|
export function scaleToBox(
|
||||||
|
natural: { width: number; height: number },
|
||||||
|
maxW: number,
|
||||||
|
maxH: number
|
||||||
|
): { width: number; height: number } {
|
||||||
|
const s = Math.min(maxW / natural.width, maxH / natural.height);
|
||||||
|
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export type NotificationEvent =
|
||||||
| "PO_APPROVED"
|
| "PO_APPROVED"
|
||||||
| "PO_APPROVED_WITH_NOTE"
|
| "PO_APPROVED_WITH_NOTE"
|
||||||
| "PO_REJECTED"
|
| "PO_REJECTED"
|
||||||
|
| "PO_CANCELLED"
|
||||||
| "EDITS_REQUESTED"
|
| "EDITS_REQUESTED"
|
||||||
| "VENDOR_ID_REQUESTED"
|
| "VENDOR_ID_REQUESTED"
|
||||||
| "VENDOR_ID_PROVIDED"
|
| "VENDOR_ID_PROVIDED"
|
||||||
|
|
@ -119,6 +120,9 @@ function buildInAppBody(
|
||||||
case "PO_REJECTED":
|
case "PO_REJECTED":
|
||||||
return `${pn} rejected`;
|
return `${pn} rejected`;
|
||||||
|
|
||||||
|
case "PO_CANCELLED":
|
||||||
|
return `${pn} has been cancelled`;
|
||||||
|
|
||||||
case "EDITS_REQUESTED":
|
case "EDITS_REQUESTED":
|
||||||
return `Edits requested on ${pn}`;
|
return `Edits requested on ${pn}`;
|
||||||
|
|
||||||
|
|
@ -215,6 +219,7 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
|
||||||
PO_APPROVED: `${base} has been approved`,
|
PO_APPROVED: `${base} has been approved`,
|
||||||
PO_APPROVED_WITH_NOTE: `${base} has been approved`,
|
PO_APPROVED_WITH_NOTE: `${base} has been approved`,
|
||||||
PO_REJECTED: `${base} has been rejected`,
|
PO_REJECTED: `${base} has been rejected`,
|
||||||
|
PO_CANCELLED: `${base} has been cancelled`,
|
||||||
EDITS_REQUESTED: `Edits requested on ${base}`,
|
EDITS_REQUESTED: `Edits requested on ${base}`,
|
||||||
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
|
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
|
||||||
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
|
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
|
||||||
|
|
@ -245,6 +250,8 @@ function buildEmailBody(
|
||||||
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
|
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
|
||||||
case "PO_REJECTED":
|
case "PO_REJECTED":
|
||||||
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
|
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
|
||||||
|
case "PO_CANCELLED":
|
||||||
|
return `Purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">cancelled</span>.${noteHtml}`;
|
||||||
case "EDITS_REQUESTED":
|
case "EDITS_REQUESTED":
|
||||||
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
|
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
|
||||||
case "VENDOR_ID_REQUESTED":
|
case "VENDOR_ID_REQUESTED":
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type Permission =
|
||||||
| "view_all_pos"
|
| "view_all_pos"
|
||||||
| "approve_po"
|
| "approve_po"
|
||||||
| "reject_po"
|
| "reject_po"
|
||||||
|
| "cancel_po"
|
||||||
| "request_edits"
|
| "request_edits"
|
||||||
| "request_vendor_id"
|
| "request_vendor_id"
|
||||||
| "process_payment"
|
| "process_payment"
|
||||||
|
|
@ -33,6 +34,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"view_all_pos",
|
"view_all_pos",
|
||||||
"approve_po",
|
"approve_po",
|
||||||
"reject_po",
|
"reject_po",
|
||||||
|
"cancel_po",
|
||||||
"request_edits",
|
"request_edits",
|
||||||
"request_vendor_id",
|
"request_vendor_id",
|
||||||
"view_analytics",
|
"view_analytics",
|
||||||
|
|
@ -53,6 +55,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"view_all_pos",
|
"view_all_pos",
|
||||||
"approve_po",
|
"approve_po",
|
||||||
"reject_po",
|
"reject_po",
|
||||||
|
"cancel_po",
|
||||||
"request_edits",
|
"request_edits",
|
||||||
"request_vendor_id",
|
"request_vendor_id",
|
||||||
"process_payment",
|
"process_payment",
|
||||||
|
|
|
||||||
32
App/lib/po-export-layout.ts
Normal file
32
App/lib/po-export-layout.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Geometry for the exported PO's left signatory block (cols A-D).
|
||||||
|
// The approver signature is centred over the name; the company stamp/seal sits to
|
||||||
|
// its RIGHT with a gap so it never overlays the signature or name — important
|
||||||
|
// because uploaded signatures/stamps aren't always transparent PNGs.
|
||||||
|
|
||||||
|
export interface Size { width: number; height: number }
|
||||||
|
|
||||||
|
export interface SignatoryLayout {
|
||||||
|
sigLeft: number | null; // px from the block's left edge, or null when no signature
|
||||||
|
stampLeft: number | null; // px from the block's left edge, or null when no stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signatoryLayout(opts: {
|
||||||
|
blockPx: number;
|
||||||
|
sig: Size | null;
|
||||||
|
stamp: Size | null;
|
||||||
|
gap?: number;
|
||||||
|
}): SignatoryLayout {
|
||||||
|
const gap = opts.gap ?? 10;
|
||||||
|
const sigLeft = opts.sig ? Math.round((opts.blockPx - opts.sig.width) / 2) : null; // centred
|
||||||
|
|
||||||
|
let stampLeft: number | null = null;
|
||||||
|
if (opts.stamp) {
|
||||||
|
stampLeft =
|
||||||
|
sigLeft != null && opts.sig
|
||||||
|
? Math.min(opts.blockPx - opts.stamp.width, sigLeft + opts.sig.width + gap) // clear of the signature
|
||||||
|
: opts.blockPx - opts.stamp.width - 6; // no signature → right-align in the block
|
||||||
|
stampLeft = Math.max(0, stampLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sigLeft, stampLeft };
|
||||||
|
}
|
||||||
|
|
@ -187,3 +187,15 @@ export function getAvailableActions(status: POStatus, role: Role): POAction[] {
|
||||||
export function requiresNote(from: POStatus, action: POAction): boolean {
|
export function requiresNote(from: POStatus, action: POAction): boolean {
|
||||||
return getTransition(from, action)?.requiresNote ?? false;
|
return getTransition(from, action)?.requiresNote ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cancellation ──────────────────────────────────────────────────────────────
|
||||||
|
// Cancellation is orthogonal to the normal lifecycle: a PO can be cancelled from
|
||||||
|
// ANY state (except when it is already cancelled), by a MANAGER or SUPERUSER, and
|
||||||
|
// always requires a reason. It is modelled separately from TRANSITIONS so it does
|
||||||
|
// not have to be enumerated on every source state.
|
||||||
|
|
||||||
|
export const CANCEL_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
||||||
|
|
||||||
|
export function canCancel(from: POStatus, role: Role): boolean {
|
||||||
|
return from !== "CANCELLED" && CANCEL_ROLES.includes(role);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
||||||
PAID_DELIVERED: "Paid",
|
PAID_DELIVERED: "Paid",
|
||||||
PARTIALLY_CLOSED: "Partially Received",
|
PARTIALLY_CLOSED: "Partially Received",
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
||||||
|
|
@ -110,4 +111,5 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
||||||
PAID_DELIVERED: "success",
|
PAID_DELIVERED: "success",
|
||||||
PARTIALLY_CLOSED: "warning",
|
PARTIALLY_CLOSED: "warning",
|
||||||
CLOSED: "secondary",
|
CLOSED: "secondary",
|
||||||
|
CANCELLED: "danger",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Cancel + supersede: a new terminal CANCELLED status, cancel metadata, and a
|
||||||
|
-- self-referential supersede link (cancelled PO -> the existing PO that replaces it).
|
||||||
|
ALTER TYPE "POStatus" ADD VALUE 'CANCELLED';
|
||||||
|
ALTER TYPE "ActionType" ADD VALUE 'CANCELLED';
|
||||||
|
ALTER TYPE "ActionType" ADD VALUE 'SUPERSEDED';
|
||||||
|
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancelledAt" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancellationReason" TEXT;
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "supersededById" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supersededById_fkey"
|
||||||
|
FOREIGN KEY ("supersededById") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -30,6 +30,7 @@ enum POStatus {
|
||||||
PAID_DELIVERED
|
PAID_DELIVERED
|
||||||
PARTIALLY_CLOSED
|
PARTIALLY_CLOSED
|
||||||
CLOSED
|
CLOSED
|
||||||
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActionType {
|
enum ActionType {
|
||||||
|
|
@ -49,6 +50,8 @@ enum ActionType {
|
||||||
REASSIGNED
|
REASSIGNED
|
||||||
PRODUCT_PRICE_UPDATED
|
PRODUCT_PRICE_UPDATED
|
||||||
MANAGER_LINE_EDIT
|
MANAGER_LINE_EDIT
|
||||||
|
CANCELLED
|
||||||
|
SUPERSEDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RequestStatus {
|
enum RequestStatus {
|
||||||
|
|
@ -270,6 +273,8 @@ model PurchaseOrder {
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
closedAt DateTime?
|
closedAt DateTime?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
cancellationReason String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -286,6 +291,12 @@ model PurchaseOrder {
|
||||||
siteId String?
|
siteId String?
|
||||||
site Site? @relation(fields: [siteId], references: [id])
|
site Site? @relation(fields: [siteId], references: [id])
|
||||||
|
|
||||||
|
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
|
||||||
|
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
||||||
|
supersededById String?
|
||||||
|
supersededBy PurchaseOrder? @relation("Supersede", fields: [supersededById], references: [id])
|
||||||
|
supersedes PurchaseOrder[] @relation("Supersede")
|
||||||
|
|
||||||
lineItems POLineItem[]
|
lineItems POLineItem[]
|
||||||
documents PODocument[]
|
documents PODocument[]
|
||||||
actions POAction[]
|
actions POAction[]
|
||||||
|
|
|
||||||
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
Binary file not shown.
|
|
@ -32,7 +32,7 @@ beforeAll(async () => {
|
||||||
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||||
getSeedUser("tech@pelagia.local"),
|
getSeedUser("tech@pelagia.local"),
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedVessel("MV Ocean Pride"),
|
getSeedVessel("MV Poseidon"),
|
||||||
getSeedAccount("700201"),
|
getSeedAccount("700201"),
|
||||||
getSeedVendor("Apar Industries Ltd"),
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
|
|
@ -52,7 +52,11 @@ async function createSubmittedPo(title: string): Promise<string> {
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
return (result as { id: string }).id;
|
const id = (result as { id: string }).id;
|
||||||
|
// Vendor gating: a vendor must be assigned before a PO can be approved.
|
||||||
|
// Attach the seeded verified vendor directly (test setup) so approval-path tests run.
|
||||||
|
await db.purchaseOrder.update({ where: { id }, data: { vendorId } });
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M-02: Approve ─────────────────────────────────────────────────────────────
|
// ── M-02: Approve ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -340,7 +344,7 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
||||||
await requestEdits({ poId, note: "Update line items" });
|
await requestEdits({ poId, note: "Update line items" });
|
||||||
|
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
|
||||||
const result = await updatePo(poId, form);
|
const result = await updatePo(poId, form);
|
||||||
expect(result).toEqual({ id: poId });
|
expect(result).toEqual({ id: poId });
|
||||||
|
|
||||||
|
|
|
||||||
181
App/tests/integration/cancel-supersede.test.ts
Normal file
181
App/tests/integration/cancel-supersede.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for PO cancellation and supersede linkage.
|
||||||
|
* Covers: cancel from any state (MANAGER/SUPERUSER, reason required), exclusion
|
||||||
|
* from spend aggregation, and linking a cancelled PO to an existing replacement.
|
||||||
|
*
|
||||||
|
* POs are built directly via db.create (not the makePoForm helper) so the test is
|
||||||
|
* self-contained and cleans up cascade-safely (POAction has no onDelete: Cascade).
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||||
|
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
|
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor } from "./helpers";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const mockedAuth = vi.mocked(auth);
|
||||||
|
const PREFIX = "INTTEST_CANCEL_";
|
||||||
|
let techId: string;
|
||||||
|
let managerId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
let accountId: string;
|
||||||
|
let vendorId: string;
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||||
|
getSeedUser("tech@pelagia.local"),
|
||||||
|
getSeedUser("manager@pelagia.local"),
|
||||||
|
getSeedVessel("MV Galatea"),
|
||||||
|
getSeedAccount("700201"),
|
||||||
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
|
]);
|
||||||
|
techId = tech.id; managerId = mgr.id;
|
||||||
|
vesselId = vessel.id; accountId = account.id; vendorId = vendor.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const pos = await db.purchaseOrder.findMany({ where: { title: { startsWith: PREFIX } }, select: { id: true } });
|
||||||
|
const ids = pos.map((p) => p.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await db.purchaseOrder.updateMany({ where: { id: { in: ids } }, data: { supersededById: null } });
|
||||||
|
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
|
||||||
|
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makePo(label: string, status: POStatus): Promise<string> {
|
||||||
|
seq += 1;
|
||||||
|
const po = await db.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
poNumber: `CANCELTEST-${seq}-${label}`,
|
||||||
|
title: `${PREFIX}${label}`,
|
||||||
|
status,
|
||||||
|
totalAmount: 1180,
|
||||||
|
currency: "INR",
|
||||||
|
vesselId,
|
||||||
|
accountId,
|
||||||
|
submitterId: techId,
|
||||||
|
...(status === "MGR_APPROVED" ? { vendorId, approvedAt: new Date() } : {}),
|
||||||
|
lineItems: { create: [{ name: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, totalPrice: 1180, gstRate: 0.18, sortOrder: 0 }] },
|
||||||
|
actions: { create: { actionType: "CREATED", actorId: techId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return po.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cancelPo", () => {
|
||||||
|
it("cancels a DRAFT PO with a reason and writes an audit row", async () => {
|
||||||
|
const poId = await makePo("Draft", "DRAFT");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
|
||||||
|
const result = await cancelPo({ poId, reason: "Duplicate order" });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(po.status).toBe("CANCELLED");
|
||||||
|
expect(po.cancelledAt).not.toBeNull();
|
||||||
|
expect(po.cancellationReason).toBe("Duplicate order");
|
||||||
|
|
||||||
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "CANCELLED" } });
|
||||||
|
expect(action?.note).toBe("Duplicate order");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an already-APPROVED PO (cancellable from any state)", async () => {
|
||||||
|
const poId = await makePo("Approved", "MGR_APPROVED");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
const result = await cancelPo({ poId, reason: "Vendor backed out" });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(po.status).toBe("CANCELLED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a cancelled PO drops out of the spend aggregation filter", async () => {
|
||||||
|
const poId = await makePo("Spend", "MGR_APPROVED");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
await cancelPo({ poId, reason: "Excluded from spend" });
|
||||||
|
|
||||||
|
expect(POST_APPROVAL_STATUSES as readonly string[]).not.toContain("CANCELLED");
|
||||||
|
const stillCounted = await db.purchaseOrder.findFirst({
|
||||||
|
where: { id: poId, status: { in: [...POST_APPROVAL_STATUSES] } },
|
||||||
|
});
|
||||||
|
expect(stillCounted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a reason", async () => {
|
||||||
|
const poId = await makePo("NoReason", "DRAFT");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
const result = await cancelPo({ poId, reason: " " });
|
||||||
|
expect(result).toEqual({ error: "A cancellation reason is required." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a role without cancel_po (TECHNICAL)", async () => {
|
||||||
|
const poId = await makePo("Forbidden", "DRAFT");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(techId, "TECHNICAL") as never);
|
||||||
|
const result = await cancelPo({ poId, reason: "nope" });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(po.status).toBe("DRAFT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to cancel an already-cancelled PO", async () => {
|
||||||
|
const poId = await makePo("Twice", "DRAFT");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
await cancelPo({ poId, reason: "first" });
|
||||||
|
const result = await cancelPo({ poId, reason: "second" });
|
||||||
|
expect(result).toEqual({ error: "This purchase order is already cancelled." });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supersedePo", () => {
|
||||||
|
it("links a cancelled PO to an existing replacement (reciprocal)", async () => {
|
||||||
|
const cancelledId = await makePo("Old", "DRAFT");
|
||||||
|
const replacementId = await makePo("New", "DRAFT");
|
||||||
|
const replacement = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId } });
|
||||||
|
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
await cancelPo({ poId: cancelledId, reason: "Replaced" });
|
||||||
|
const result = await supersedePo({ poId: cancelledId, replacementPoNumber: replacement.poNumber });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const old = await db.purchaseOrder.findUniqueOrThrow({ where: { id: cancelledId } });
|
||||||
|
expect(old.supersededById).toBe(replacementId);
|
||||||
|
|
||||||
|
const repl = await db.purchaseOrder.findUniqueOrThrow({
|
||||||
|
where: { id: replacementId },
|
||||||
|
include: { supersedes: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
expect(repl.supersedes.map((s) => s.id)).toContain(cancelledId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to supersede a PO that is not cancelled", async () => {
|
||||||
|
const poId = await makePo("NotCancelled", "DRAFT");
|
||||||
|
const otherId = await makePo("Other", "DRAFT");
|
||||||
|
const other = await db.purchaseOrder.findUniqueOrThrow({ where: { id: otherId } });
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
const result = await supersedePo({ poId, replacementPoNumber: other.poNumber });
|
||||||
|
expect(result).toEqual({ error: "Only a cancelled purchase order can be superseded." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown replacement PO number", async () => {
|
||||||
|
const poId = await makePo("Unknown", "DRAFT");
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
await cancelPo({ poId, reason: "x" });
|
||||||
|
const result = await supersedePo({ poId, replacementPoNumber: "PMS/ZZZ/0000/2000-01" });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects self-supersede", async () => {
|
||||||
|
const poId = await makePo("Self", "DRAFT");
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||||
|
await cancelPo({ poId, reason: "x" });
|
||||||
|
const result = await supersedePo({ poId, replacementPoNumber: po.poNumber });
|
||||||
|
expect(result).toEqual({ error: "A purchase order cannot supersede itself." });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
getSeedUser,
|
getSeedUser,
|
||||||
getSeedVessel,
|
getSeedVessel,
|
||||||
getSeedAccount,
|
getSeedAccount,
|
||||||
|
getSeedVendor,
|
||||||
makePoForm,
|
makePoForm,
|
||||||
deletePosByTitle,
|
deletePosByTitle,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
@ -32,20 +33,23 @@ let managerId: string;
|
||||||
let accountsId: string;
|
let accountsId: string;
|
||||||
let vesselId: string;
|
let vesselId: string;
|
||||||
let accountId: string;
|
let accountId: string;
|
||||||
|
let vendorId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||||
getSeedUser("tech@pelagia.local"),
|
getSeedUser("tech@pelagia.local"),
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedUser("accounts@pelagia.local"),
|
getSeedUser("accounts@pelagia.local"),
|
||||||
getSeedVessel("MV Sea Breeze"),
|
getSeedVessel("MV Nereid"),
|
||||||
getSeedAccount("700202"),
|
getSeedAccount("700202"),
|
||||||
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
techId = tech.id;
|
techId = tech.id;
|
||||||
managerId = mgr.id;
|
managerId = mgr.id;
|
||||||
accountsId = acct.id;
|
accountsId = acct.id;
|
||||||
vesselId = vessel.id;
|
vesselId = vessel.id;
|
||||||
accountId = account.id;
|
accountId = account.id;
|
||||||
|
vendorId = vendor.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -57,6 +61,8 @@ async function createPaidPo(title: string): Promise<string> {
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
// Vendor gating: approval requires an assigned vendor.
|
||||||
|
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||||
|
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await approvePo({ poId });
|
await approvePo({ poId });
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ let vendorId: string;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const [tech, vessel, account, vendor] = await Promise.all([
|
const [tech, vessel, account, vendor] = await Promise.all([
|
||||||
getSeedUser("tech@pelagia.local"),
|
getSeedUser("tech@pelagia.local"),
|
||||||
getSeedVessel("MV Ocean Pride"),
|
getSeedVessel("MV Aegean Wind"),
|
||||||
getSeedAccount("700201"),
|
getSeedAccount("700201"),
|
||||||
getSeedVendor("Apar Industries Ltd"),
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
|
|
@ -79,7 +79,7 @@ describe("S-02 — save as draft", () => {
|
||||||
form.set("title", `${PREFIX}NoVessel`);
|
form.set("title", `${PREFIX}NoVessel`);
|
||||||
form.set("accountId", accountId);
|
form.set("accountId", accountId);
|
||||||
form.set("intent", "draft");
|
form.set("intent", "draft");
|
||||||
form.set("lineItems[0].description", "Item");
|
form.set("lineItems[0].name", "Item");
|
||||||
form.set("lineItems[0].quantity", "1");
|
form.set("lineItems[0].quantity", "1");
|
||||||
form.set("lineItems[0].unit", "pc");
|
form.set("lineItems[0].unit", "pc");
|
||||||
form.set("lineItems[0].unitPrice", "50");
|
form.set("lineItems[0].unitPrice", "50");
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedUser("accounts@pelagia.local"),
|
getSeedUser("accounts@pelagia.local"),
|
||||||
getSeedVessel("MV Pelagia Star"),
|
getSeedVessel("MV Pelagia Star"),
|
||||||
getSeedAccount("TECH-OPS"),
|
getSeedAccount("700201"),
|
||||||
]);
|
]);
|
||||||
techId = tech.id;
|
techId = tech.id;
|
||||||
managerId = mgr.id;
|
managerId = mgr.id;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export function appendLineItem(
|
||||||
idx: number,
|
idx: number,
|
||||||
item: { description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }
|
item: { description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }
|
||||||
) {
|
) {
|
||||||
form.set(`lineItems[${idx}].description`, item.description);
|
form.set(`lineItems[${idx}].name`, item.description);
|
||||||
form.set(`lineItems[${idx}].quantity`, String(item.quantity));
|
form.set(`lineItems[${idx}].quantity`, String(item.quantity));
|
||||||
form.set(`lineItems[${idx}].unit`, item.unit);
|
form.set(`lineItems[${idx}].unit`, item.unit);
|
||||||
form.set(`lineItems[${idx}].unitPrice`, String(item.unitPrice));
|
form.set(`lineItems[${idx}].unitPrice`, String(item.unitPrice));
|
||||||
|
|
@ -58,7 +58,7 @@ export function makePoForm(overrides: {
|
||||||
vesselId: string;
|
vesselId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
vendorId?: string;
|
vendorId?: string;
|
||||||
intent?: "draft" | "submit";
|
intent?: "draft" | "submit" | "resubmit";
|
||||||
lineItems?: Array<{ description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }>;
|
lineItems?: Array<{ description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }>;
|
||||||
}): FormData {
|
}): FormData {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|
@ -76,12 +76,23 @@ export function makePoForm(overrides: {
|
||||||
|
|
||||||
// ── Cleanup helpers ──────────────────────────────────────────────────────────
|
// ── Cleanup helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POAction and Receipt have no onDelete: Cascade, so their rows must be removed
|
||||||
|
// before the PO. (POLineItem / PODocument cascade automatically.)
|
||||||
|
async function deletePosByIds(ids: string[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
|
||||||
|
await db.receipt.deleteMany({ where: { poId: { in: ids } } });
|
||||||
|
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePo(poId: string) {
|
export async function deletePo(poId: string) {
|
||||||
await db.purchaseOrder.delete({ where: { id: poId } }).catch(() => {});
|
await deletePosByIds([poId]).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePosByTitle(titlePrefix: string) {
|
export async function deletePosByTitle(titlePrefix: string) {
|
||||||
await db.purchaseOrder.deleteMany({
|
const pos = await db.purchaseOrder.findMany({
|
||||||
where: { title: { startsWith: titlePrefix } },
|
where: { title: { startsWith: titlePrefix } },
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
await deletePosByIds(pos.map((p) => p.id));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { POST } from "@/app/api/po/import/route";
|
||||||
import { makeSession, getSeedUser } from "./helpers";
|
import { makeSession, getSeedUser } from "./helpers";
|
||||||
import type { ParsedImport } from "@/lib/po-import-parser";
|
import type { ParsedImport } from "@/lib/po-import-parser";
|
||||||
|
|
||||||
const SAMPLE_XLSX = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
const SAMPLE_XLSX = resolve(__dirname, "../fixtures/Sample_PO.xlsx");
|
||||||
|
|
||||||
let techId: string;
|
let techId: string;
|
||||||
let managerId: string;
|
let managerId: string;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedUser("accounts@pelagia.local"),
|
getSeedUser("accounts@pelagia.local"),
|
||||||
getSeedVessel("MV Pelagia Star"),
|
getSeedVessel("MV Pelagia Star"),
|
||||||
getSeedAccount("TECH-OPS"),
|
getSeedAccount("700201"),
|
||||||
getSeedVendor("Apar Industries Ltd"),
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
managerId = mgr.id;
|
managerId = mgr.id;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { createPo } from "@/app/(portal)/po/new/actions";
|
||||||
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||||
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
||||||
import {
|
import {
|
||||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount,
|
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||||
makePoForm, deletePosByTitle,
|
makePoForm, deletePosByTitle,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -25,20 +25,23 @@ let managerId: string;
|
||||||
let accountsId: string;
|
let accountsId: string;
|
||||||
let vesselId: string;
|
let vesselId: string;
|
||||||
let accountId: string;
|
let accountId: string;
|
||||||
|
let vendorId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||||
getSeedUser("tech@pelagia.local"),
|
getSeedUser("tech@pelagia.local"),
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedUser("accounts@pelagia.local"),
|
getSeedUser("accounts@pelagia.local"),
|
||||||
getSeedVessel("MV Sea Breeze"),
|
getSeedVessel("MV Thetis"),
|
||||||
getSeedAccount("700202"),
|
getSeedAccount("700202"),
|
||||||
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
techId = tech.id;
|
techId = tech.id;
|
||||||
managerId = mgr.id;
|
managerId = mgr.id;
|
||||||
accountsId = acct.id;
|
accountsId = acct.id;
|
||||||
vesselId = vessel.id;
|
vesselId = vessel.id;
|
||||||
accountId = account.id;
|
accountId = account.id;
|
||||||
|
vendorId = vendor.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -50,6 +53,8 @@ async function createApprovedPo(title: string): Promise<string> {
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
// Vendor gating: approval requires an assigned vendor.
|
||||||
|
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||||
|
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await approvePo({ poId });
|
await approvePo({ poId });
|
||||||
|
|
@ -146,14 +151,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
expect(calls).toContain("PAYMENT_SENT");
|
expect(calls).toContain("PAYMENT_SENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
|
it("TECHNICAL role cannot mark as paid (no process_payment permission)", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
|
const poId = await createApprovedPo(`${PREFIX}PaidTechForbidden`);
|
||||||
|
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
|
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
|
const result = await markPaid({ poId, paymentRef: "TECH-REF", paymentDate: TODAY });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,8 @@ describe("GET /api/products/search — search behaviour", () => {
|
||||||
it("finds products by product code", async () => {
|
it("finds products by product code", async () => {
|
||||||
const res = await GET(makeRequest("LUBE"));
|
const res = await GET(makeRequest("LUBE"));
|
||||||
const data: { code: string }[] = await res.json();
|
const data: { code: string }[] = await res.json();
|
||||||
expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
// search spans code/name/description, so assert the code matches are present (not that every hit is a code match)
|
||||||
|
expect(data.some((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds products by description text", async () => {
|
it("finds products by description text", async () => {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* - Unverified vendor rejected by provideVendorId
|
* - Unverified vendor rejected by provideVendorId
|
||||||
* - AUDITOR cannot provide vendor ID
|
* - AUDITOR cannot provide vendor ID
|
||||||
*/
|
*/
|
||||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
@ -39,7 +39,7 @@ beforeAll(async () => {
|
||||||
getSeedUser("manager@pelagia.local"),
|
getSeedUser("manager@pelagia.local"),
|
||||||
getSeedUser("accounts@pelagia.local"),
|
getSeedUser("accounts@pelagia.local"),
|
||||||
getSeedVessel("MV Pelagia Star"),
|
getSeedVessel("MV Pelagia Star"),
|
||||||
getSeedAccount("TECH-OPS"),
|
getSeedAccount("700201"),
|
||||||
getSeedVendor("Apar Industries Ltd"),
|
getSeedVendor("Apar Industries Ltd"),
|
||||||
]);
|
]);
|
||||||
techId = tech.id;
|
techId = tech.id;
|
||||||
|
|
@ -66,15 +66,22 @@ beforeAll(async () => {
|
||||||
auditorId = created.id;
|
auditorId = created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab an unverified vendor
|
// A vendor with no formal vendorId code — provideVendorId must reject it.
|
||||||
const unverified = await db.vendor.findFirst({ where: { isVerified: false } });
|
// (Seeded "unverified" vendors can still carry a code, so create a code-less one.)
|
||||||
unverifiedVendorDbId = unverified!.id;
|
const noCode = await db.vendor.create({
|
||||||
|
data: { name: `${PREFIX}NoCodeVendor`, isVerified: false, vendorId: null },
|
||||||
|
});
|
||||||
|
unverifiedVendorDbId = noCode.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await deletePosByTitle(PREFIX);
|
await deletePosByTitle(PREFIX);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||||
|
});
|
||||||
|
|
||||||
async function makeReviewPo(title: string, withVendor = false) {
|
async function makeReviewPo(title: string, withVendor = false) {
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
|
|
|
||||||
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
|
||||||
|
vi.mock("@/app/(portal)/po/[id]/actions", () => ({ cancelPo: vi.fn(), supersedePo: vi.fn() }));
|
||||||
|
|
||||||
|
import { CancelPoButton } from "@/components/po/cancel-po-controls";
|
||||||
|
|
||||||
|
// Regression guard: the theme only defines danger / -50 / -100 / -700, so an
|
||||||
|
// undefined shade like bg-danger-600 renders no background → the button was
|
||||||
|
// invisible (white text on nothing). Both cancel buttons must use `bg-danger`.
|
||||||
|
|
||||||
|
describe("CancelPoButton", () => {
|
||||||
|
it("renders the trigger as a filled red (bg-danger) button with white text", () => {
|
||||||
|
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||||
|
const btn = screen.getByRole("button", { name: "Cancel PO" });
|
||||||
|
// standalone `bg-danger` (a defined token), NOT `bg-danger-600` (undefined → invisible)
|
||||||
|
expect(btn.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||||
|
expect(btn.className).toContain("text-white");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a modal whose confirm button is a visible filled danger button", () => {
|
||||||
|
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||||
|
|
||||||
|
const confirm = screen.getByRole("button", { name: "Cancel this PO" });
|
||||||
|
expect(confirm.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||||
|
expect(confirm.className).toContain("text-white");
|
||||||
|
|
||||||
|
// Keep PO is always present as the safe default.
|
||||||
|
expect(screen.getByRole("button", { name: "Keep PO" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the confirm action disabled until 'cancel' is typed and a reason given", () => {
|
||||||
|
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||||
|
|
||||||
|
const confirm = screen.getByRole("button", { name: "Cancel this PO" }) as HTMLButtonElement;
|
||||||
|
expect(confirm.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Duplicate order/i), { target: { value: "No longer needed" } });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("cancel"), { target: { value: "cancel" } });
|
||||||
|
expect(confirm.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
App/tests/unit/image-size.test.ts
Normal file
55
App/tests/unit/image-size.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||||
|
|
||||||
|
function fakePng(width: number, height: number): Buffer {
|
||||||
|
const b = Buffer.alloc(24);
|
||||||
|
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47; // PNG signature start
|
||||||
|
b.writeUInt32BE(width, 16);
|
||||||
|
b.writeUInt32BE(height, 20);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeJpeg(width: number, height: number): Buffer {
|
||||||
|
const b = Buffer.alloc(20);
|
||||||
|
b[0] = 0xff; b[1] = 0xd8; // SOI
|
||||||
|
b[2] = 0xff; b[3] = 0xc0; // SOF0 marker
|
||||||
|
b.writeUInt16BE(0x11, 4); // segment length
|
||||||
|
b[6] = 8; // precision
|
||||||
|
b.writeUInt16BE(height, 7);
|
||||||
|
b.writeUInt16BE(width, 9);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getImageSize", () => {
|
||||||
|
it("reads PNG dimensions", () => {
|
||||||
|
expect(getImageSize(fakePng(640, 480))).toEqual({ width: 640, height: 480 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads JPEG dimensions from the SOF marker", () => {
|
||||||
|
expect(getImageSize(fakeJpeg(1024, 768))).toEqual({ width: 1024, height: 768 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-image data", () => {
|
||||||
|
expect(getImageSize(Buffer.from("not an image at all"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scaleToBox", () => {
|
||||||
|
it("preserves a square aspect ratio (downscale by the binding side)", () => {
|
||||||
|
const r = scaleToBox({ width: 200, height: 200 }, 96, 52);
|
||||||
|
expect(r.width).toBe(r.height); // stays square — never stretched
|
||||||
|
expect(r.height).toBeLessThanOrEqual(52);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits a wide image to the width and keeps the ratio", () => {
|
||||||
|
const r = scaleToBox({ width: 360, height: 96 }, 165, 44);
|
||||||
|
expect(r.width).toBeLessThanOrEqual(165);
|
||||||
|
expect(r.height).toBeLessThanOrEqual(44);
|
||||||
|
expect(r.width / r.height).toBeCloseTo(360 / 96, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the watermark's landscape ratio", () => {
|
||||||
|
const r = scaleToBox({ width: 1400, height: 1000 }, 880, 720);
|
||||||
|
expect(r).toEqual({ width: 880, height: 629 });
|
||||||
|
});
|
||||||
|
});
|
||||||
39
App/tests/unit/po-export-layout.test.ts
Normal file
39
App/tests/unit/po-export-layout.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||||
|
|
||||||
|
const BLOCK = 503; // px width of the A-D signatory block
|
||||||
|
|
||||||
|
describe("signatoryLayout", () => {
|
||||||
|
it("centres the signature in the block", () => {
|
||||||
|
const { sigLeft } = signatoryLayout({ blockPx: BLOCK, sig: { width: 153, height: 44 }, stamp: null });
|
||||||
|
expect(sigLeft).not.toBeNull();
|
||||||
|
expect(sigLeft! + 153 / 2).toBeCloseTo(BLOCK / 2, 0); // centre ≈ block centre
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places the stamp to the RIGHT of the signature with no overlap", () => {
|
||||||
|
const sig = { width: 153, height: 44 };
|
||||||
|
const stamp = { width: 67, height: 66 };
|
||||||
|
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp, gap: 10 });
|
||||||
|
expect(stampLeft! ).toBeGreaterThanOrEqual(sigLeft! + sig.width); // starts at/after signature ends
|
||||||
|
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); // stays inside the block
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overlaps even with the widest signature + stamp", () => {
|
||||||
|
const sig = { width: 165, height: 44 }; // scaleToBox caps
|
||||||
|
const stamp = { width: 80, height: 66 }; // scaleToBox caps
|
||||||
|
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp });
|
||||||
|
expect(stampLeft!).toBeGreaterThanOrEqual(sigLeft! + sig.width);
|
||||||
|
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-aligns the stamp when there is no signature", () => {
|
||||||
|
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig: null, stamp: { width: 67, height: 66 } });
|
||||||
|
expect(sigLeft).toBeNull();
|
||||||
|
expect(stampLeft! + 67).toBeLessThanOrEqual(BLOCK);
|
||||||
|
expect(stampLeft!).toBeGreaterThan(BLOCK / 2); // on the right side
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nulls when there are no images", () => {
|
||||||
|
expect(signatoryLayout({ blockPx: BLOCK, sig: null, stamp: null })).toEqual({ sigLeft: null, stampLeft: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
64
App/tests/unit/vendors-table.test.tsx
Normal file
64
App/tests/unit/vendors-table.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Row = Parameters<typeof VendorsTable>[0]["vendors"][number];
|
||||||
|
|
||||||
|
const makeRow = (over: Partial<Row> = {}): Row => ({
|
||||||
|
id: "v1",
|
||||||
|
name: "Acme Marine Supplies",
|
||||||
|
vendorId: "VND-001",
|
||||||
|
gstin: null,
|
||||||
|
address: null,
|
||||||
|
isVerified: false,
|
||||||
|
itemCount: 0,
|
||||||
|
primaryContact: null,
|
||||||
|
distanceKm: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VendorsTable — vendor id (issue #57)", () => {
|
||||||
|
it("renders the vendorId next to the name when present", () => {
|
||||||
|
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||||
|
expect(screen.getByText("Acme Marine Supplies")).toBeTruthy();
|
||||||
|
expect(screen.getByText("VND-001")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the id (no placeholder) when vendorId is null", () => {
|
||||||
|
render(<VendorsTable vendors={[makeRow({ vendorId: null })]} hasSite={false} />);
|
||||||
|
expect(screen.queryByText("VND-001")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by vendorId", () => {
|
||||||
|
const rows = [
|
||||||
|
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||||
|
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||||
|
];
|
||||||
|
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||||
|
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||||
|
fireEvent.change(search, { target: { value: "VND-999" } });
|
||||||
|
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||||
|
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still filters by name", () => {
|
||||||
|
const rows = [
|
||||||
|
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||||
|
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||||
|
];
|
||||||
|
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||||
|
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||||
|
fireEvent.change(search, { target: { value: "beta" } });
|
||||||
|
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises ID search in the placeholder", () => {
|
||||||
|
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||||
|
expect(screen.getByPlaceholderText(/Search by name, ID, GSTIN or address/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -161,16 +161,22 @@ portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
After merging a Claude PR (or any change) on `master`:
|
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on
|
||||||
|
> `v*` tags — a bare tag like `0.2.2` will **NOT** deploy (the runner ignores it and prod
|
||||||
|
> stays on the previous version). Push the **tag** specifically; pushing `master` alone
|
||||||
|
> never deploys.
|
||||||
|
|
||||||
|
After merging PR(s) on `master`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git pull
|
git pull
|
||||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
git tag v0.2.2 # MUST start with "v"; semver: patch = fixes, minor = features
|
||||||
git push pms1 master --tags
|
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
The runner deploys the tag and restarts the app. Watch progress under
|
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` +
|
||||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch
|
||||||
|
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||||
|
|
||||||
## Operational notes
|
## Operational notes
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue