Compare commits

..

No commits in common. "master" and "fix/triage-owns-portal-routing" have entirely different histories.

55 changed files with 152 additions and 1781 deletions

View file

@ -31,13 +31,7 @@ jobs:
pnpm build # includes prisma generate pnpm build # includes prisma generate
pnpm db:migrate:deploy pnpm db:migrate:deploy
# NOT --update-env: this job runs inside the Forgejo Actions runner, whose pm2 restart ppms --update-env
# environment includes an ephemeral FORGEJO_TOKEN (the per-job token, revoked
# when the job ends). --update-env would inject it into ppms, where it shadows
# the real PAT from .env (Next.js does not override an already-set process.env
# var) and breaks the Report Issue button once the job token expires. A plain
# restart re-execs ppms from the pm2 daemon's clean env, so .env wins.
pm2 restart ppms
echo "=== Deployed $TAG ===" echo "=== Deployed $TAG ==="
- name: Verify portal responds - name: Verify portal responds

View file

@ -4,7 +4,6 @@ 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:
@ -57,45 +56,3 @@ 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

View file

@ -1,27 +0,0 @@
name: Refresh staging
# Rebuilds the pms1 staging instance (pm2 `ppms-staging`, port 3200) to the latest
# master on every merge to master, so staging always mirrors the trunk for
# smoke-testing before a release tag. Also runnable on demand (workflow_dispatch).
# See automation/README.md > "Staging".
on:
push:
branches: [master]
workflow_dispatch: {}
# Only one staging refresh at a time; a newer master push cancels an in-flight build
# (staging-up.sh always checks out the latest origin/master, so the newest wins).
concurrency:
group: refresh-staging
cancel-in-progress: true
jobs:
refresh:
runs-on: host
steps:
- name: Rebuild staging on latest master
run: |
set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
"$HOME/issue-watcher/staging-up.sh"

View file

@ -1,39 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage";
import { redirect, notFound } from "next/navigation";
import { CompanyForm } from "../../company-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Edit Company" };
export default async function EditCompanyPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const { id } = await params;
const c = await db.company.findUnique({ where: { id } });
if (!c) notFound();
return (
<CompanyForm
company={{
id: c.id,
name: c.name,
code: c.code,
gstNumber: c.gstNumber,
address: c.address,
telephone: c.telephone,
mobile: c.mobile,
email: c.email,
invoiceEmail: c.invoiceEmail,
invoiceAddress: c.invoiceAddress,
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
isActive: c.isActive,
}}
/>
);
}

View file

@ -3,21 +3,11 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
import { z } from "zod"; import { z } from "zod";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string }; type ActionResult = { ok: true } | { error: string };
// Branding assets (logo + stamp) shown on exported POs.
const ASSET_MIME: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/webp": "webp",
};
const ASSET_MAX_BYTES = 4 * 1024 * 1024; // 4 MB — banners/seals can be larger than signatures
const companySchema = z.object({ const companySchema = z.object({
name: z.string().min(1, "Company name is required"), name: z.string().min(1, "Company name is required"),
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(), code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
@ -30,7 +20,7 @@ const companySchema = z.object({
invoiceAddress: z.string().optional(), invoiceAddress: z.string().optional(),
}); });
export async function createCompany(formData: FormData): Promise<{ ok: true; id: string } | { error: string }> { export async function createCompany(formData: FormData): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" }; return { error: "Unauthorized" };
@ -54,11 +44,11 @@ export async function createCompany(formData: FormData): Promise<{ ok: true; id:
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } }); const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` }; if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
} }
const created = await db.company.create({ await db.company.create({
data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null }, data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
}); });
revalidatePath("/admin/companies"); revalidatePath("/admin/companies");
return { ok: true, id: created.id }; return { ok: true };
} }
export async function updateCompany(formData: FormData): Promise<ActionResult> { export async function updateCompany(formData: FormData): Promise<ActionResult> {
@ -108,58 +98,6 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
return { ok: true }; return { ok: true };
} }
// ── Branding assets (logo + stamp) ──────────────────────────────────────────────
export async function uploadCompanyAsset(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
const companyId = formData.get("companyId") as string | null;
const type = formData.get("type") as string | null;
if (!companyId) return { error: "Company ID is required" };
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
const company = await db.company.findUnique({ where: { id: companyId }, select: { id: true } });
if (!company) return { error: "Company not found" };
const file = formData.get("file") as File | null;
if (!file || file.size === 0) return { error: "No file provided" };
if (file.size > ASSET_MAX_BYTES) return { error: "Image must be under 4 MB" };
const ext = ASSET_MIME[file.type];
if (!ext) return { error: "Image must be a PNG, JPG, or WebP" };
const key = buildCompanyAssetKey(companyId, type, ext);
const buffer = Buffer.from(await file.arrayBuffer());
await uploadBuffer(key, buffer, file.type);
await db.company.update({
where: { id: companyId },
data: type === "logo" ? { logoKey: key } : { stampKey: key },
});
revalidatePath("/admin/companies");
return { ok: true };
}
export async function removeCompanyAsset(companyId: string, type: "logo" | "stamp"): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
return { error: "Unauthorized" };
}
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
await db.company.update({
where: { id: companyId },
data: type === "logo" ? { logoKey: null } : { stampKey: null },
});
revalidatePath("/admin/companies");
return { ok: true };
}
export async function toggleCompanyActive(id: string): Promise<ActionResult> { export async function toggleCompanyActive(id: string): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {

View file

@ -1,8 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import { AddCompanyButton, EditCompanyButton } from "./company-form";
import { useRouter } from "next/navigation";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@ -23,20 +22,21 @@ export type CompanyRow = {
}; };
function CompanyActionsMenu({ company }: { company: CompanyRow }) { function CompanyActionsMenu({ company }: { company: CompanyRow }) {
const router = useRouter(); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
return ( return (
<> <>
<RowActionsMenu> <RowActionsMenu>
<RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>Edit</RowActionsItem> <RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}> <RowActionsItem onClick={() => setToggleOpen(true)}>
{company.isActive ? "Deactivate" : "Activate"} {company.isActive ? "Deactivate" : "Activate"}
</RowActionsItem> </RowActionsItem>
<RowActionsSeparator /> <RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem> <RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu> </RowActionsMenu>
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog <DeleteConfirmDialog
open={deleteOpen} onOpenChange={setDeleteOpen} open={deleteOpen} onOpenChange={setDeleteOpen}
label={company.name} onConfirm={() => deleteCompany(company.id)} label={company.name} onConfirm={() => deleteCompany(company.id)}
@ -60,10 +60,7 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1> <h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1>
<p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p> <p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
</div> </div>
<Link href="/admin/companies/new" <AddCompanyButton />
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Company
</Link>
</div> </div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">

View file

@ -1,120 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Upload, X } from "lucide-react";
import { uploadCompanyAsset, removeCompanyAsset } from "./actions";
interface Props {
companyId: string;
type: "logo" | "stamp";
label: string;
hint: string;
currentUrl: string | null;
}
export function CompanyBrandingUploader({ companyId, type, label, hint, currentUrl }: Props) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState("");
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setPreview(URL.createObjectURL(file));
}
async function handleUpload() {
const file = inputRef.current?.files?.[0];
if (!file) { setError("Please select a file first"); return; }
const fd = new FormData();
fd.append("companyId", companyId);
fd.append("type", type);
fd.append("file", file);
setPending(true);
setError("");
const result = await uploadCompanyAsset(fd);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setPreview(null);
if (inputRef.current) inputRef.current.value = "";
router.refresh();
}
}
async function handleRemove() {
setRemoving(true);
setError("");
const result = await removeCompanyAsset(companyId, type);
setRemoving(false);
if ("error" in result) setError(result.error);
else { setPreview(null); router.refresh(); }
}
const displayUrl = preview ?? currentUrl;
return (
<div className="rounded-lg border border-neutral-200 p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-neutral-700">{label}</p>
{currentUrl && !preview && (
<button
type="button"
onClick={handleRemove}
disabled={removing}
className="inline-flex items-center gap-1 text-xs font-medium text-danger-700 hover:text-danger-800 disabled:opacity-50"
>
<X className="h-3 w-3" />
{removing ? "Removing…" : "Remove"}
</button>
)}
</div>
{displayUrl && (
<div className="rounded border border-neutral-200 bg-white p-2 inline-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={displayUrl} alt={label} className="max-h-16 max-w-full object-contain" />
{preview && <p className="text-[10px] text-neutral-400 mt-1">Preview not yet saved</p>}
</div>
)}
<div
className="relative rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 px-4 py-3 text-center cursor-pointer hover:border-primary-400 hover:bg-primary-50 transition-colors"
onClick={() => inputRef.current?.click()}
>
<Upload className="mx-auto h-5 w-5 text-neutral-400 mb-1" />
<p className="text-xs text-neutral-600">Click to select image</p>
<p className="text-[10px] text-neutral-400 mt-0.5">{hint}</p>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp"
onChange={handleFileChange}
className="sr-only"
/>
</div>
{error && <p className="text-xs text-danger-700 bg-danger-50 rounded px-2 py-1">{error}</p>}
{preview && (
<button
type="button"
onClick={handleUpload}
disabled={pending}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Uploading…" : "Upload"}
</button>
)}
</div>
);
}

View file

@ -2,12 +2,10 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { ArrowLeft } from "lucide-react";
import { createCompany, updateCompany } from "./actions"; import { createCompany, updateCompany } from "./actions";
import { CompanyBrandingUploader } from "./company-branding-uploader";
export type CompanyFormData = { type CompanyRow = {
id: string; id: string;
name: string; name: string;
code: string | null; code: string | null;
@ -18,15 +16,13 @@ export type CompanyFormData = {
email: string | null; email: string | null;
invoiceEmail: string | null; invoiceEmail: string | null;
invoiceAddress: string | null; invoiceAddress: string | null;
logoUrl: string | null;
stampUrl: string | null;
isActive: boolean; isActive: boolean;
}; };
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const LABEL = "block text-xs font-medium text-neutral-700 mb-1"; const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
function CompanyFormFields({ company }: { company?: CompanyFormData }) { function CompanyFormFields({ company }: { company?: CompanyRow }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
@ -75,79 +71,92 @@ function CompanyFormFields({ company }: { company?: CompanyFormData }) {
); );
} }
export function CompanyForm({ company }: { company?: CompanyFormData }) { export function AddCompanyButton() {
const router = useRouter(); const router = useRouter();
const isEdit = !!company?.id; const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault(); setPending(true); setError("");
setPending(true); const result = await createCompany(new FormData(e.currentTarget));
setError(""); if ("error" in result) { setError(result.error); setPending(false); }
const fd = new FormData(e.currentTarget); else { setPending(false); setOpen(false); router.refresh(); }
if (isEdit) {
fd.set("id", company!.id);
const result = await updateCompany(fd);
if ("error" in result) { setError(result.error); setPending(false); return; }
router.push("/admin/companies");
router.refresh();
} else {
const result = await createCompany(fd);
if ("error" in result) { setError(result.error); setPending(false); return; }
// Land on the edit page so the logo/stamp can be uploaded against the new company.
router.push(`/admin/companies/${result.id}/edit`);
router.refresh();
}
} }
return ( return (
<div className="max-w-3xl"> <>
<Link href="/admin/companies" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-700 mb-3"> <button onClick={() => setOpen(true)}
<ArrowLeft className="h-3.5 w-3.5" /> Back to Companies className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
</Link> + Add Company
<h1 className="text-2xl font-semibold text-neutral-900">{isEdit ? `Edit — ${company!.name}` : "Add Company"}</h1> </button>
<p className="text-sm text-neutral-500 mt-0.5 mb-6">Sister company used for invoicing and purchase orders</p> <AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4"> <CompanyFormFields />
<div className="rounded-lg border border-neutral-200 bg-white p-5"> {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<CompanyFormFields company={company} /> <div className="flex justify-end gap-3 pt-1">
</div> <button type="button" onClick={() => setOpen(false)}
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<div className="flex justify-end gap-3"> <button type="submit" disabled={pending}
<Link href="/admin/companies" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"> {pending ? "Creating…" : "Create Company"}
Cancel </button>
</Link>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? (isEdit ? "Saving…" : "Creating…") : (isEdit ? "Save Changes" : "Create Company")}
</button>
</div>
</form>
{/* ── Branding (independent uploads; available once the company exists) ── */}
<div className="rounded-lg border border-neutral-200 bg-white p-5 mt-6">
<h2 className="text-sm font-semibold text-neutral-800">Branding</h2>
<p className="text-xs text-neutral-400 mb-3">Logo and stamp shown on exported POs</p>
{isEdit ? (
<div className="grid grid-cols-2 gap-4">
<CompanyBrandingUploader
companyId={company!.id} type="logo" label="Logo"
hint="PNG, JPG or WebP — shown top-left. Max 4 MB"
currentUrl={company!.logoUrl}
/>
<CompanyBrandingUploader
companyId={company!.id} type="stamp" label="Stamp / Seal"
hint="PNG, JPG or WebP — shown in signatory block. Max 4 MB"
currentUrl={company!.stampUrl}
/>
</div> </div>
) : ( </form>
<p className="text-xs text-neutral-400">Create the company first you&apos;ll be taken to the edit page where you can upload a logo and stamp.</p> </AdminDialog>
)} </>
</div> );
</div> }
export function EditCompanyButton({
company,
open: controlledOpen,
onOpenChange,
}: {
company: CompanyRow;
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", company.id);
const result = await updateCompany(fd);
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
{!isControlled && (
<button onClick={() => setOpen(true)}
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
Edit
</button>
)}
<AdminDialog title={`Edit — ${company.name}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CompanyFormFields company={company} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
</>
); );
} }

View file

@ -1,15 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { CompanyForm } from "../company-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Add Company" };
export default async function NewCompanyPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
return <CompanyForm />;
}

View file

@ -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", CANCELLED: "Cancelled", SUBMITTED: "Submitted", REJECTED: "Rejected",
}; };
return ( return (

View file

@ -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", CANCELLED: "Cancelled", PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
}; };

View file

@ -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", CANCELLED: "Cancelled", PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
}; };
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")

View file

@ -3,8 +3,8 @@ import { db } from "@/lib/db";
import { StatCard } from "@/components/dashboard/stat-card"; import { StatCard } from "@/components/dashboard/stat-card";
import { SpendCharts } from "@/components/dashboard/spend-charts"; import { SpendCharts } from "@/components/dashboard/spend-charts";
import { PoStatusBadge } from "@/components/po/po-status-badge"; import { PoStatusBadge } from "@/components/po/po-status-badge";
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils"; import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react"; import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -182,7 +182,7 @@ async function ManagerDashboard() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" /> <StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} /> <StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" /> <StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
</div> </div>
{/* Recent approved POs */} {/* Recent approved POs */}

View file

@ -14,7 +14,6 @@ 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 {

View file

@ -115,10 +115,7 @@ 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 <tr key={po.id} className="hover:bg-neutral-50">
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}

View file

@ -41,7 +41,6 @@ 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))
) )
@ -90,7 +89,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, ID, GSTIN or address…" placeholder="Search by name, 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 && (
@ -152,9 +151,6 @@ 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>
)} )}

View file

@ -2,8 +2,7 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canPerformAction, canCancel } from "@/lib/po-state-machine"; import { canPerformAction } 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";
@ -114,118 +113,3 @@ 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 };
}

View file

@ -32,8 +32,6 @@ 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 } },
}, },
}); });

View file

@ -4,8 +4,6 @@ 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";
// ── Company fallback constants (used when no company is linked to a PO) ────── // ── Company fallback constants (used when no company is linked to a PO) ──────
@ -25,25 +23,6 @@ function fmtNum(n: number, dec = 2): string {
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec }); return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
} }
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
const BRAND_BAR_COLOR = "#92D050";
function mimeForKey(key: string): string {
const ext = key.split(".").pop()?.toLowerCase();
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
}
interface EmbeddedImage { base64: string; mime: string; width: number; height: number }
// 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;
const buf = await downloadBuffer(key);
if (!buf) return null;
const size = getImageSize(buf) ?? { width: 100, height: 100 };
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
}
// ── Route ───────────────────────────────────────────────────────────────────── // ── Route ─────────────────────────────────────────────────────────────────────
interface Props { params: Promise<{ id: string }> } interface Props { params: Promise<{ id: string }> }
@ -70,11 +49,9 @@ export async function GET(request: NextRequest, { params }: Props) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO // Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
// 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", "CANCELLED"]; const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
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." },
@ -133,7 +110,6 @@ 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 },
@ -145,15 +121,10 @@ 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 };
} }
} }
} }
// Company branding (logo top-left, stamp/seal in the signatory block)
const logoImg = await fetchImage(co?.logoKey);
const stampImg = await fetchImage(co?.stampKey);
const ext = po as { const ext = po as {
piQuotationNo?: string | null; piQuotationDate?: Date | null; piQuotationNo?: string | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionDate?: Date | null; requisitionNo?: string | null; requisitionDate?: Date | null;
@ -284,19 +255,6 @@ 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; aspect preserved) ═════
if (logoImg) {
const logoId = wb.addImage({
base64: logoImg.base64,
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(logoId, {
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
ext: scaleToBox(logoImg, 96, 52),
editAs: "oneCell",
});
}
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════ // ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
ws.getRow(5).height = 18; ws.getRow(5).height = 18;
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL }); sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
@ -459,49 +417,16 @@ 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 signatory block (cols A-D). Position images by absolute pixels via native // Left sig block (approver — the manager who authorized the PO)
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels. if (signatureBase64) {
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 sigLeft = sigExt ? Math.round((SIG_BLOCK_PX - sigExt.width) / 2) : 0; // centred over the name
// Stamp / seal — drawn FIRST so it sits BEHIND the signature, tucked to its right.
if (stampImg) {
const stampExt = scaleToBox(stampImg, 80, 66);
const stampLeft = sigExt
? Math.min(SIG_BLOCK_PX - stampExt.width, sigLeft + sigExt.width - Math.round(stampExt.width * 0.35))
: SIG_BLOCK_PX - stampExt.width - 6;
const stampId = wb.addImage({
base64: stampImg.base64,
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(stampId, {
tl: anchorAt(Math.max(0, stampLeft), SIG_ROW - 1),
ext: stampExt,
editAs: "oneCell",
});
}
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
if (signatureBase64 && sigExt) {
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, {
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1), // eslint-disable-next-line @typescript-eslint/no-explicit-any
ext: sigExt, tl: { col: 0, row: SIG_ROW - 1 } as any,
// 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() } });
@ -529,27 +454,6 @@ export async function GET(request: NextRequest, { params }: Props) {
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC }); sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`); ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
// ══ Brand bar (full-width colour strip at the very bottom) ═══════════════
const BAR_ROW = SIG_ROW + 4;
const barArgb = "FF" + BRAND_BAR_COLOR.replace("#", "").toUpperCase();
const barFill = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: barArgb } };
ws.getRow(BAR_ROW).height = 16;
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
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, "-");
@ -602,20 +506,9 @@ export async function GET(request: NextRequest, { params }: Props) {
color: #111; color: #111;
margin: 10mm 12mm; margin: 10mm 12mm;
line-height: 1.3; line-height: 1.3;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
} }
/* ── Header ── */ /* ── Header ── */
.header-band { position: relative; }
.co-logo {
position: absolute;
left: 0;
top: 0;
max-height: 52px;
max-width: 92px;
object-fit: contain;
}
.co-name { .co-name {
text-align: center; text-align: center;
font-size: 13pt; font-size: 13pt;
@ -675,7 +568,6 @@ export async function GET(request: NextRequest, { params }: Props) {
/* ── Signatures ── */ /* ── Signatures ── */
.sig { display: flex; justify-content: space-between; margin-top: 14px; } .sig { display: flex; justify-content: space-between; margin-top: 14px; }
.sig-box { .sig-box {
position: relative;
border: 1px solid #999; border: 1px solid #999;
width: 44%; width: 44%;
min-height: 60px; min-height: 60px;
@ -687,44 +579,9 @@ export async function GET(request: NextRequest, { params }: Props) {
} }
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; } .sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
.sig-sub { font-size: 7.5pt; } .sig-sub { font-size: 7.5pt; }
.sig-stamp {
position: absolute;
right: 6px;
top: 4px;
max-height: 66px;
max-width: 88px;
object-fit: contain;
pointer-events: none;
}
.spacer { margin: 4px 0; } .spacer { margin: 4px 0; }
/* ── Brand bar (bottom) ── */
.brand-bar {
height: 14px;
width: 100%;
margin-top: 12px;
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; }
@ -734,8 +591,6 @@ 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
@ -743,12 +598,9 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
</div> </div>
<!-- ── Header ─────────────────────────────────────────────────── --> <!-- ── Header ─────────────────────────────────────────────────── -->
<div class="header-band"> <div class="co-name">${CO_NAME}</div>
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""} <div class="co-addr">${CO_ADDR}</div>
<div class="co-name">${CO_NAME}</div> <div class="co-tel">${CO_TEL}</div>
<div class="co-addr">${CO_ADDR}</div>
<div class="co-tel">${CO_TEL}</div>
</div>
<div class="po-title">PURCHASE ORDER</div> <div class="po-title">PURCHASE ORDER</div>
<!-- ── PO Meta & Quotation ──────────────────────────────────── --> <!-- ── PO Meta & Quotation ──────────────────────────────────── -->
@ -866,7 +718,6 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<!-- ── Signatures ────────────────────────────────────────────── --> <!-- ── Signatures ────────────────────────────────────────────── -->
<div class="sig"> <div class="sig">
<div class="sig-box"> <div class="sig-box">
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
${signatureBase64 ${signatureBase64
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />` ? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
: `<div class="sig-name">${approvedBy}</div>` : `<div class="sig-name">${approvedBy}</div>`
@ -874,7 +725,7 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<div> <div>
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div> <div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
<div class="sig-sub">Authorized Signatory &amp; Stamp</div> <div class="sig-sub">Authorized Signatory &amp; Stamp</div>
<div class="sig-sub">For, ${CO_NAME}</div> <div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
</div> </div>
</div> </div>
<div class="sig-box"> <div class="sig-box">
@ -886,9 +737,6 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
</div> </div>
</div> </div>
<!-- ── Brand bar ─────────────────────────────────────────────── -->
<div class="brand-bar"></div>
<script>window.onload = function() { window.print(); };</script> <script>window.onload = function() { window.print(); };</script>
</body> </body>
</html>`; </html>`;

View file

@ -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", CANCELLED: "Cancelled", PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
}; };
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {

View file

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

View file

@ -3,7 +3,6 @@ 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";
@ -41,10 +40,6 @@ 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 };
@ -97,8 +92,6 @@ 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) {
@ -210,8 +203,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && ( !readOnly && (
<DiscardDraftButton poId={po.id} /> <DiscardDraftButton poId={po.id} />
)} )}
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */} {/* Export buttons — only available once the PO has been approved by a manager */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<> {["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].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"
@ -227,59 +220,9 @@ 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">

View file

@ -20,11 +20,8 @@ const UOM_OPTIONS = [
{ value: "mL", label: "mL — Millilitre" }, { value: "mL", label: "mL — Millilitre" },
{ value: "m", label: "m — Metre" }, { value: "m", label: "m — Metre" },
{ value: "m2", label: "m² — Sq. Metre" }, { value: "m2", label: "m² — Sq. Metre" },
{ value: "hr", label: "hr — Hour" }, { value: "hr", label: "hr — Hour" },
{ value: "day", label: "day — Day" }, { value: "day", label: "day — Day" },
{ value: "week", label: "week — Week" },
{ value: "month", label: "month — Month" },
{ value: "year", label: "year — Year" },
{ value: "lump", label: "lump — Lump Sum" }, { value: "lump", label: "lump — Lump Sum" },
{ value: "Ltr", label: "Ltr — Litre (alt)" }, { value: "Ltr", label: "Ltr — Litre (alt)" },
]; ];

File diff suppressed because one or more lines are too long

View file

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

View file

@ -12,7 +12,6 @@ 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"
@ -120,9 +119,6 @@ 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}`;
@ -219,7 +215,6 @@ 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}`,
@ -250,8 +245,6 @@ 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":

View file

@ -8,7 +8,6 @@ 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"
@ -34,7 +33,6 @@ 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",
@ -55,7 +53,6 @@ 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",

View file

@ -187,15 +187,3 @@ 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);
}

View file

@ -57,18 +57,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
return `signatures/${userId}.${ext}`; return `signatures/${userId}.${ext}`;
} }
/**
* Storage key for a company branding asset (logo or stamp/seal).
* Deterministic per company+type so a re-upload overwrites the previous file.
*/
export function buildCompanyAssetKey(
companyId: string,
type: "logo" | "stamp",
ext: string
): string {
return `company-assets/${companyId}/${type}.${ext}`;
}
/** /**
* Upload a file buffer directly to storage (server-side). * Upload a file buffer directly to storage (server-side).
* In dev: writes to .dev-uploads/. In prod: PUTs to R2. * In dev: writes to .dev-uploads/. In prod: PUTs to R2.

View file

@ -12,30 +12,6 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
); );
} }
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
export function formatCompactINR(amount: number | string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return "₹0";
const sign = n < 0 ? "-" : "";
const abs = Math.abs(n);
const format = (value: number, suffix: string) => {
const rounded = Math.round(value * 100) / 100;
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
return `${sign}${text}${suffix}`;
};
if (abs >= 1e7) return format(abs / 1e7, " Cr");
if (abs >= 1e5) return format(abs / 1e5, " L");
if (abs >= 1e3) return format(abs / 1e3, " K");
return format(abs, "");
}
export function formatDate(date: Date | string): string { export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
year: "numeric", year: "numeric",
@ -75,7 +51,6 @@ 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
@ -111,5 +86,4 @@ 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",
}; };

View file

@ -1,3 +0,0 @@
-- Add branding to Company: logo + stamp images, shown on exported POs
ALTER TABLE "Company" ADD COLUMN "logoKey" TEXT;
ALTER TABLE "Company" ADD COLUMN "stampKey" TEXT;

View file

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

View file

@ -30,7 +30,6 @@ enum POStatus {
PAID_DELIVERED PAID_DELIVERED
PARTIALLY_CLOSED PARTIALLY_CLOSED
CLOSED CLOSED
CANCELLED
} }
enum ActionType { enum ActionType {
@ -50,8 +49,6 @@ enum ActionType {
REASSIGNED REASSIGNED
PRODUCT_PRICE_UPDATED PRODUCT_PRICE_UPDATED
MANAGER_LINE_EDIT MANAGER_LINE_EDIT
CANCELLED
SUPERSEDED
} }
enum RequestStatus { enum RequestStatus {
@ -128,8 +125,6 @@ model Company {
email String? email String?
invoiceEmail String? invoiceEmail String?
invoiceAddress String? invoiceAddress String?
logoKey String? // storage key for uploaded logo image (top of exported POs)
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -273,8 +268,6 @@ 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
@ -291,12 +284,6 @@ 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[]

Binary file not shown.

View file

@ -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 Poseidon"), getSeedVessel("MV Ocean Pride"),
getSeedAccount("700201"), getSeedAccount("700201"),
getSeedVendor("Apar Industries Ltd"), getSeedVendor("Apar Industries Ltd"),
]); ]);
@ -52,11 +52,7 @@ 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);
const id = (result as { id: string }).id; return (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 ─────────────────────────────────────────────────────────────
@ -344,7 +340,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: "resubmit" }); const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
const result = await updatePo(poId, form); const result = await updatePo(poId, form);
expect(result).toEqual({ id: poId }); expect(result).toEqual({ id: poId });

View file

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

View file

@ -1,114 +0,0 @@
/**
* Integration tests for company branding actions (logo + stamp uploads).
* Covers:
* - Manager can upload a logo / stamp; the key is stored on the company
* - Re-upload overwrites in place (deterministic key)
* - Invalid asset type, bad mime, and oversize files are rejected
* - removeCompanyAsset clears the key
* - Permission gating (TECHNICAL cannot manage branding)
*/
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/storage")>()),
uploadBuffer: vi.fn(), // don't touch the filesystem in tests
}));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import { uploadCompanyAsset, removeCompanyAsset } from "@/app/(portal)/admin/companies/actions";
import { makeSession } from "./helpers";
const mockedAuth = vi.mocked(auth);
const mockedUpload = vi.mocked(uploadBuffer);
let companyId: string;
function pngFile(name: string, bytes = 1024): File {
return new File([new Uint8Array(bytes)], name, { type: "image/png" });
}
function assetForm(id: string, type: string, file: File): FormData {
const form = new FormData();
form.set("companyId", id);
form.set("type", type);
form.set("file", file);
return form;
}
beforeAll(async () => {
const company = await db.company.create({
data: { name: "INTTEST_BRANDING_CO", code: "ZZBRAND" },
});
companyId = company.id;
});
afterAll(async () => {
await db.company.delete({ where: { id: companyId } }).catch(() => {});
});
describe("uploadCompanyAsset", () => {
it("stores a logo key on the company", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
expect(res).toEqual({ ok: true });
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
expect(mockedUpload).toHaveBeenCalled();
});
it("stores a stamp key independently of the logo", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const res = await uploadCompanyAsset(assetForm(companyId, "stamp", pngFile("stamp.png")));
expect(res).toEqual({ ok: true });
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`);
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
});
it("rejects an unknown asset type", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const res = await uploadCompanyAsset(assetForm(companyId, "header", pngFile("x.png")));
expect(res).toEqual({ error: "Invalid asset type" });
});
it("rejects a non-image mime type", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const pdf = new File([new Uint8Array(10)], "x.pdf", { type: "application/pdf" });
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pdf));
expect(res).toEqual({ error: "Image must be a PNG, JPG, or WebP" });
});
it("rejects a file over 4 MB", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const big = pngFile("big.png", 5 * 1024 * 1024);
const res = await uploadCompanyAsset(assetForm(companyId, "logo", big));
expect(res).toEqual({ error: "Image must be under 4 MB" });
});
it("refuses callers without manage_vessels_accounts", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
expect(res).toEqual({ error: "Unauthorized" });
});
});
describe("removeCompanyAsset", () => {
it("clears the stored key", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const res = await removeCompanyAsset(companyId, "logo");
expect(res).toEqual({ ok: true });
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
expect(c.logoKey).toBeNull();
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`); // stamp untouched
});
it("refuses unauthorized callers", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
const res = await removeCompanyAsset(companyId, "stamp");
expect(res).toEqual({ error: "Unauthorized" });
});
});

View file

@ -1,84 +0,0 @@
/**
* Integration tests for company create/update actions.
* Focus on the behaviour the dedicated add/edit pages rely on:
* - createCompany returns the new id (so the create flow can redirect to the edit page)
* - fields persist, code is upper-cased, duplicate codes are rejected
* - updateCompany edits in place
* - both actions are gated by manage_vessels_accounts
*/
import { vi, describe, it, expect, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { createCompany, updateCompany } from "@/app/(portal)/admin/companies/actions";
import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth);
const NAME_PREFIX = "INTTEST_CRUD_";
afterAll(async () => {
await db.company.deleteMany({ where: { name: { startsWith: NAME_PREFIX } } });
});
describe("createCompany", () => {
it("returns the new id and persists the company (code upper-cased)", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const result = await createCompany(fd({
name: `${NAME_PREFIX}Alpha`,
code: "zzcrudA",
gstNumber: "27AAHCP5787B1Z6",
}));
expect("id" in result && result.ok).toBe(true);
if (!("id" in result)) throw new Error(result.error);
const c = await db.company.findUniqueOrThrow({ where: { id: result.id } });
expect(c.name).toBe(`${NAME_PREFIX}Alpha`);
expect(c.code).toBe("ZZCRUDA");
expect(c.gstNumber).toBe("27AAHCP5787B1Z6");
});
it("rejects a duplicate code (case-insensitive)", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const first = await createCompany(fd({ name: `${NAME_PREFIX}Dup1`, code: "zzcrudd" }));
expect("id" in first).toBe(true);
const second = await createCompany(fd({ name: `${NAME_PREFIX}Dup2`, code: "ZZCRUDD" }));
expect("error" in second).toBe(true);
});
it("refuses callers without manage_vessels_accounts", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
const result = await createCompany(fd({ name: `${NAME_PREFIX}Nope`, code: "zzcrudN" }));
expect(result).toEqual({ error: "Unauthorized" });
});
});
describe("updateCompany", () => {
it("edits an existing company in place", async () => {
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
const created = await createCompany(fd({ name: `${NAME_PREFIX}Edit`, code: "zzcrudE" }));
if (!("id" in created)) throw new Error(created.error);
const result = await updateCompany(fd({
id: created.id,
name: `${NAME_PREFIX}Edited`,
code: "zzcrudE",
mobile: "+91 99999 00000",
}));
expect(result).toEqual({ ok: true });
const c = await db.company.findUniqueOrThrow({ where: { id: created.id } });
expect(c.name).toBe(`${NAME_PREFIX}Edited`);
expect(c.mobile).toBe("+91 99999 00000");
});
it("refuses callers without manage_vessels_accounts", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
const result = await updateCompany(fd({ id: "whatever", name: "x", code: "ZZX" }));
expect(result).toEqual({ error: "Unauthorized" });
});
});

View file

@ -20,7 +20,6 @@ import {
getSeedUser, getSeedUser,
getSeedVessel, getSeedVessel,
getSeedAccount, getSeedAccount,
getSeedVendor,
makePoForm, makePoForm,
deletePosByTitle, deletePosByTitle,
} from "./helpers"; } from "./helpers";
@ -33,23 +32,20 @@ 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, vendor] = await Promise.all([ const [tech, mgr, acct, vessel, account] = 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 Nereid"), getSeedVessel("MV Sea Breeze"),
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 () => {
@ -61,8 +57,6 @@ 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 });

View file

@ -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 Aegean Wind"), getSeedVessel("MV Ocean Pride"),
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].name", "Item"); form.set("lineItems[0].description", "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");

View file

@ -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("700201"), getSeedAccount("TECH-OPS"),
]); ]);
techId = tech.id; techId = tech.id;
managerId = mgr.id; managerId = mgr.id;

View file

@ -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}].name`, item.description); form.set(`lineItems[${idx}].description`, 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" | "resubmit"; intent?: "draft" | "submit";
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,23 +76,12 @@ 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 deletePosByIds([poId]).catch(() => {}); await db.purchaseOrder.delete({ where: { id: poId } }).catch(() => {});
} }
export async function deletePosByTitle(titlePrefix: string) { export async function deletePosByTitle(titlePrefix: string) {
const pos = await db.purchaseOrder.findMany({ await db.purchaseOrder.deleteMany({
where: { title: { startsWith: titlePrefix } }, where: { title: { startsWith: titlePrefix } },
select: { id: true },
}); });
await deletePosByIds(pos.map((p) => p.id));
} }

View file

@ -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, "../fixtures/Sample_PO.xlsx"); const SAMPLE_XLSX = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
let techId: string; let techId: string;
let managerId: string; let managerId: string;

View file

@ -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("700201"), getSeedAccount("TECH-OPS"),
getSeedVendor("Apar Industries Ltd"), getSeedVendor("Apar Industries Ltd"),
]); ]);
managerId = mgr.id; managerId = mgr.id;

View file

@ -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, getSeedVendor, makeSession, getSeedUser, getSeedVessel, getSeedAccount,
makePoForm, deletePosByTitle, makePoForm, deletePosByTitle,
} from "./helpers"; } from "./helpers";
@ -25,23 +25,20 @@ 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, vendor] = await Promise.all([ const [tech, mgr, acct, vessel, account] = 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 Thetis"), getSeedVessel("MV Sea Breeze"),
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 () => {
@ -53,8 +50,6 @@ 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 });
@ -151,14 +146,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
expect(calls).toContain("PAYMENT_SENT"); expect(calls).toContain("PAYMENT_SENT");
}); });
it("TECHNICAL role cannot mark as paid (no process_payment permission)", async () => { it("MANAGER role cannot mark as paid (wrong permission)", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidTechForbidden`); const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
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(techId, "TECHNICAL")); vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await markPaid({ poId, paymentRef: "TECH-REF", paymentDate: TODAY }); const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
expect(result).toHaveProperty("error"); expect(result).toHaveProperty("error");
}); });
}); });

View file

@ -91,8 +91,7 @@ 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();
// search spans code/name/description, so assert the code matches are present (not that every hit is a code match) expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
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 () => {

View file

@ -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, afterAll, afterEach } from "vitest"; import { vi, describe, it, expect, beforeAll, 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("700201"), getSeedAccount("TECH-OPS"),
getSeedVendor("Apar Industries Ltd"), getSeedVendor("Apar Industries Ltd"),
]); ]);
techId = tech.id; techId = tech.id;
@ -66,22 +66,15 @@ beforeAll(async () => {
auditorId = created.id; auditorId = created.id;
} }
// A vendor with no formal vendorId code — provideVendorId must reject it. // Grab an unverified vendor
// (Seeded "unverified" vendors can still carry a code, so create a code-less one.) const unverified = await db.vendor.findFirst({ where: { isVerified: false } });
const noCode = await db.vendor.create({ unverifiedVendorDbId = unverified!.id;
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({

View file

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

View file

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

View file

@ -93,25 +93,6 @@ describe("LineItemsEditor — edit mode", () => {
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[]; const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
expect(lastCall[0].gstRate).toBeCloseTo(0.05); expect(lastCall[0].gstRate).toBeCloseTo(0.05);
}); });
it("offers month and year as unit-of-measure options", () => {
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const unitSelect = selects.find((s) => s.value === "pc")!;
const values = Array.from(unitSelect.options).map((o) => o.value);
expect(values).toContain("month");
expect(values).toContain("year");
});
it("calls onChange with the selected duration unit", async () => {
const onChange = vi.fn();
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
const unitSelect = selects.find((s) => s.value === "pc")!;
fireEvent.change(unitSelect, { target: { value: "year" } });
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
expect(lastCall[0].unit).toBe("year");
});
}); });
// ── Totals calculation (edit mode) ──────────────────────────────────────────── // ── Totals calculation (edit mode) ────────────────────────────────────────────

View file

@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildCompanyAssetKey, buildSignatureKey } from "@/lib/storage";
describe("buildCompanyAssetKey", () => {
it("builds a deterministic logo key under the company namespace", () => {
expect(buildCompanyAssetKey("cmp123", "logo", "png")).toBe("company-assets/cmp123/logo.png");
});
it("builds a deterministic stamp key", () => {
expect(buildCompanyAssetKey("cmp123", "stamp", "webp")).toBe("company-assets/cmp123/stamp.webp");
});
it("is stable across re-uploads of the same type (overwrites in place)", () => {
const a = buildCompanyAssetKey("c1", "logo", "png");
const b = buildCompanyAssetKey("c1", "logo", "png");
expect(a).toBe(b);
});
it("separates logo and stamp into distinct keys", () => {
expect(buildCompanyAssetKey("c1", "logo", "png")).not.toBe(buildCompanyAssetKey("c1", "stamp", "png"));
});
});
describe("buildSignatureKey", () => {
it("keeps signatures in their own namespace", () => {
expect(buildSignatureKey("u1", "png")).toBe("signatures/u1.png");
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
formatCurrency, formatCompactINR, formatDate, formatDateTime, formatCurrency, formatDate, formatDateTime,
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS, generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
} from "@/lib/utils"; } from "@/lib/utils";
@ -32,55 +32,6 @@ describe("formatCurrency", () => {
}); });
}); });
describe("formatCompactINR", () => {
it("abbreviates crore amounts with Cr", () => {
expect(formatCompactINR(20000000)).toBe("₹2 Cr");
});
it("abbreviates lakh amounts with L", () => {
expect(formatCompactINR(4900000)).toBe("₹49 L");
});
it("abbreviates thousand amounts with K", () => {
expect(formatCompactINR(75000)).toBe("₹75 K");
});
it("renders sub-thousand amounts without a suffix", () => {
expect(formatCompactINR(500)).toBe("₹500");
});
it("formats zero as ₹0", () => {
expect(formatCompactINR(0)).toBe("₹0");
});
it("trims trailing zeros but keeps significant decimals", () => {
expect(formatCompactINR(25000000)).toBe("₹2.5 Cr");
expect(formatCompactINR(4950000)).toBe("₹49.5 L");
});
it("rounds to at most two decimals", () => {
expect(formatCompactINR(12345678)).toBe("₹1.23 Cr");
});
it("uses the right unit at boundaries", () => {
expect(formatCompactINR(100000)).toBe("₹1 L");
expect(formatCompactINR(10000000)).toBe("₹1 Cr");
expect(formatCompactINR(1000)).toBe("₹1 K");
});
it("accepts string input", () => {
expect(formatCompactINR("4900000")).toBe("₹49 L");
});
it("preserves the sign for negative amounts", () => {
expect(formatCompactINR(-4900000)).toBe("-₹49 L");
});
it("handles non-finite input gracefully", () => {
expect(formatCompactINR(NaN)).toBe("₹0");
});
});
describe("formatDate", () => { describe("formatDate", () => {
it("returns a readable date string", () => { it("returns a readable date string", () => {
const result = formatDate(new Date("2026-04-29")); const result = formatDate(new Date("2026-04-29"));

View file

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

View file

@ -121,11 +121,7 @@ before a release tag deploys them to prod.
- Checkout: `~/pelagia-staging` (separate from `~/pms` and `~/pelagia-autofix`) - Checkout: `~/pelagia-staging` (separate from `~/pms` and `~/pelagia-autofix`)
- Process: pm2 `ppms-staging` on **port 3200**, against the prod-mirror test DB - Process: pm2 `ppms-staging` on **port 3200**, against the prod-mirror test DB
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled). (`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
- **Auto-refresh:** [`.forgejo/workflows/staging.yml`](../.forgejo/workflows/staging.yml) - Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`.
rebuilds staging on **every push to `master`** (i.e. every merged PR) on the host runner,
so staging always tracks the trunk. It runs `~/issue-watcher/staging-up.sh`; concurrent
runs are coalesced (newest master wins). Also triggerable on demand (`workflow_dispatch`).
- Manual refresh / restart: re-run `~/issue-watcher/staging-up.sh`.
- Stop: `pm2 delete ppms-staging`. - Stop: `pm2 delete ppms-staging`.
- **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is - **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is
not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`: not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`:
@ -161,22 +157,16 @@ portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─
## Releasing ## Releasing
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on After merging a Claude PR (or any change) on `master`:
> `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.2 # MUST start with "v"; semver: patch = fixes, minor = features git tag v0.2.0 # semver: bump patch for fixes, minor for features
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy git push pms1 master --tags
``` ```
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` + The runner deploys the tag and restarts the app. Watch progress under
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
## Operational notes ## Operational notes

View file

@ -67,12 +67,8 @@ echo "Generating Prisma client..."; pnpm db:generate
# must be applied or the new code 500s on the missing columns. # must be applied or the new code 500s on the missing columns.
echo "Applying pending migrations to the test DB..."; pnpm db:migrate:deploy echo "Applying pending migrations to the test DB..."; pnpm db:migrate:deploy
# Drop any FORGEJO_* the caller may carry (e.g. when invoked from the Forgejo
# Actions runner, whose ephemeral FORGEJO_TOKEN would otherwise be injected into
# the staging process). NOT --update-env on restart, for the same reason.
for v in $(env | grep -oE '^FORGEJO_[A-Z_]+' || true); do unset "$v"; done
if pm2 describe "$NAME" >/dev/null 2>&1; then if pm2 describe "$NAME" >/dev/null 2>&1; then
pm2 restart "$NAME" pm2 restart "$NAME" --update-env
else else
pm2 start "$DIR/App/run-staging.sh" --name "$NAME" --interpreter bash pm2 start "$DIR/App/run-staging.sh" --name "$NAME" --interpreter bash
fi fi