Compare commits
47 commits
docs/retir
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c4c370f6 | |||
| 65a9335de1 | |||
| cb661949d9 | |||
| 610c9aa56d | |||
| 6677ef4fcf | |||
| 4fee393c84 | |||
| 3b9bc0be1b | |||
| 0fdd899096 | |||
| 43d139234e | |||
|
|
cb25d2e5fd | ||
| 9de60200f9 | |||
| a8d772d63b | |||
| a197b966b1 | |||
| 058ba1d12e | |||
| 0b10ba5e54 | |||
| fbdc7b2235 | |||
| 9e787fd15f | |||
| 8ee077e548 | |||
| 991b7ca5dd | |||
| 4c53aeecb0 | |||
| b70eec261b | |||
| d9394e6afb | |||
| 4712fafb4b | |||
| e388ec917e | |||
| 9d08ca1990 | |||
| 6137d11e5f | |||
|
|
defd6e7a18 | ||
| bad67f66c4 | |||
| 74d20cd452 | |||
| 467f0ddea4 | |||
| 64fefd15a8 | |||
| 1071cb226f | |||
| a0c6ccba3c | |||
| 9f8297aa7e | |||
| 783051933a | |||
|
|
e9e618fda8 | ||
| 5bb3549142 | |||
| 2d6681014d | |||
| 1feb43186d | |||
| 794bbf8e7e | |||
| 520b1527e0 | |||
| aec6d2971f | |||
| 064e4ebf66 | |||
| 0fe043e833 | |||
| 8406397602 | |||
| 938ff6df89 | |||
| debac55a8a |
61 changed files with 2121 additions and 273 deletions
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!-- All changes land via PR — no direct pushes to master. -->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- Brief summary of the change and the motivation / linked issue (e.g. Closes #NN). -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] **Tests** added or updated for this change — or it is a docs/config/automation-only PR (tests not applicable). Model: the integration test on `claude/issue-12` (prod-mirror DB, raw-SQL inserts, prefix-isolated, cleans up after itself).
|
||||
- [ ] **Docs** updated where relevant (App/README.md, App/CLAUDE.md, Docs/, automation/README.md, CHANGELOG.md).
|
||||
- [ ] `pnpm type-check` is clean and `pnpm test` passes (the PR check enforces both).
|
||||
- [ ] Verified the change (how: unit/integration tests, or a dev server on port 3100 against the test DB).
|
||||
|
||||
<!--
|
||||
The "PR checks" workflow runs on every PR and hard-fails on: a code change with no
|
||||
test change, any type-check error, or any failing unit test.
|
||||
-->
|
||||
|
|
@ -31,7 +31,13 @@ jobs:
|
|||
pnpm build # includes prisma generate
|
||||
pnpm db:migrate:deploy
|
||||
|
||||
pm2 restart ppms --update-env
|
||||
# NOT --update-env: this job runs inside the Forgejo Actions runner, whose
|
||||
# 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 ==="
|
||||
|
||||
- name: Verify portal responds
|
||||
|
|
|
|||
101
.forgejo/workflows/pr-checks.yml
Normal file
101
.forgejo/workflows/pr-checks.yml
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
name: PR checks
|
||||
|
||||
# Enforces the contribution policy on every PR into master (all gates hard):
|
||||
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||
# - type-check is clean across the whole project (tests included)
|
||||
# - unit tests pass
|
||||
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Policy — code changes must include tests
|
||||
run: |
|
||||
set -uo pipefail
|
||||
base="${GITHUB_BASE_REF:-master}"
|
||||
git fetch origin "$base" --depth=200 -q
|
||||
changed=$(git diff --name-only "origin/$base...HEAD")
|
||||
printf 'Changed files:\n%s\n\n' "$changed"
|
||||
|
||||
# "Code" = app source (pages, API routes, lib, components, hooks).
|
||||
# Tests, prisma, config, docs, automation and .forgejo are exempt.
|
||||
code=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' \
|
||||
| grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
|
||||
tests=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
|
||||
|
||||
if [ -n "$code" ] && [ -z "$tests" ]; then
|
||||
echo "::error::Code changed but no test files changed."
|
||||
echo "Every code PR must add or update tests (model: the claude/issue-12 integration test)."
|
||||
echo "If a test is genuinely not applicable, say why in the PR description so a reviewer can override."
|
||||
printf '\nCode files without accompanying tests:\n%s\n' "$code"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — test-presence policy satisfied."
|
||||
|
||||
- name: Type-check (no errors)
|
||||
run: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||
cd App
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:generate # prisma client types (no DB connection needed)
|
||||
pnpm type-check # whole project, tests included — must be clean
|
||||
|
||||
- name: Unit tests
|
||||
run: |
|
||||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||
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
|
||||
27
.forgejo/workflows/staging.yml
Normal file
27
.forgejo/workflows/staging.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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"
|
||||
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal file
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,21 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
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({
|
||||
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(),
|
||||
|
|
@ -20,7 +30,7 @@ const companySchema = z.object({
|
|||
invoiceAddress: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createCompany(formData: FormData): Promise<ActionResult> {
|
||||
export async function createCompany(formData: FormData): Promise<{ ok: true; id: string } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
|
|
@ -44,11 +54,11 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
|
|||
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.` };
|
||||
}
|
||||
await db.company.create({
|
||||
const created = 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 },
|
||||
});
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
return { ok: true, id: created.id };
|
||||
}
|
||||
|
||||
export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
||||
|
|
@ -98,6 +108,58 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
|
|||
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> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AddCompanyButton, EditCompanyButton } from "./company-form";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
|
@ -22,21 +23,20 @@ export type CompanyRow = {
|
|||
};
|
||||
|
||||
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [toggleOpen, setToggleOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||
{company.isActive ? "Deactivate" : "Activate"}
|
||||
</RowActionsItem>
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<DeleteConfirmDialog
|
||||
open={deleteOpen} onOpenChange={setDeleteOpen}
|
||||
label={company.name} onConfirm={() => deleteCompany(company.id)}
|
||||
|
|
@ -60,7 +60,10 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
|
|||
<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>
|
||||
</div>
|
||||
<AddCompanyButton />
|
||||
<Link href="/admin/companies/new"
|
||||
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 className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
|
|
|
|||
120
App/app/(portal)/admin/companies/company-branding-uploader.tsx
Normal file
120
App/app/(portal)/admin/companies/company-branding-uploader.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createCompany, updateCompany } from "./actions";
|
||||
import { CompanyBrandingUploader } from "./company-branding-uploader";
|
||||
|
||||
type CompanyRow = {
|
||||
export type CompanyFormData = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
|
|
@ -16,13 +18,15 @@ type CompanyRow = {
|
|||
email: string | null;
|
||||
invoiceEmail: string | null;
|
||||
invoiceAddress: string | null;
|
||||
logoUrl: string | null;
|
||||
stampUrl: string | null;
|
||||
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 LABEL = "block text-xs font-medium text-neutral-700 mb-1";
|
||||
|
||||
function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
||||
function CompanyFormFields({ company }: { company?: CompanyFormData }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
|
@ -71,92 +75,79 @@ function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function AddCompanyButton() {
|
||||
export function CompanyForm({ company }: { company?: CompanyFormData }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isEdit = !!company?.id;
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setPending(true); setError("");
|
||||
const result = await createCompany(new FormData(e.currentTarget));
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { setPending(false); setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||
+ Add Company
|
||||
</button>
|
||||
<AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CompanyFormFields />
|
||||
{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 ? "Creating…" : "Create Company"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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("");
|
||||
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(); }
|
||||
|
||||
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 (
|
||||
<>
|
||||
{!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">
|
||||
<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">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back to Companies
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{isEdit ? `Edit — ${company!.name}` : "Add Company"}</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5 mb-6">Sister company used for invoicing and purchase orders</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<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>
|
||||
{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">
|
||||
<Link href="/admin/companies"
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||
Cancel
|
||||
</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>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-400">Create the company first — you'll be taken to the edit page where you can upload a logo and stamp.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
15
App/app/(portal)/admin/companies/new/page.tsx
Normal file
15
App/app/(portal)/admin/companies/new/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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 />;
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
||||
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
||||
SUBMITTED: "Submitted", REJECTED: "Rejected",
|
||||
SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
|||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ async function ManagerDashboard() {
|
|||
<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="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* Recent approved POs */}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const STATUSES = [
|
|||
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{orders.map((po) => (
|
||||
<tr key={po.id} className="hover:bg-neutral-50">
|
||||
<tr
|
||||
key={po.id}
|
||||
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
||||
{po.poNumber}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function VendorsTable({
|
|||
? vendors.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
(v.vendorId && v.vendorId.toLowerCase().includes(q)) ||
|
||||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
||||
(v.address && v.address.toLowerCase().includes(q))
|
||||
)
|
||||
|
|
@ -89,7 +90,7 @@ export function VendorsTable({
|
|||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, GSTIN or address…"
|
||||
placeholder="Search by name, ID, GSTIN or address…"
|
||||
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
{query && (
|
||||
|
|
@ -151,6 +152,9 @@ export function VendorsTable({
|
|||
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
{vendor.name}
|
||||
</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 && (
|
||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { canPerformAction, canCancel } from "@/lib/po-state-machine";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
|
|
@ -113,3 +114,118 @@ export async function discardDraftPo(
|
|||
revalidatePath("/dashboard");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Cancel a PO ───────────────────────────────────────────────────────────────
|
||||
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
|
||||
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
|
||||
// explicit whitelists, none of which include CANCELLED).
|
||||
|
||||
export async function cancelPo({
|
||||
poId,
|
||||
reason,
|
||||
}: {
|
||||
poId: string;
|
||||
reason: string;
|
||||
}): Promise<{ ok: true } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||
return { error: "You do not have permission to cancel purchase orders." };
|
||||
}
|
||||
|
||||
const trimmed = (reason ?? "").trim();
|
||||
if (!trimmed) return { error: "A cancellation reason is required." };
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
include: { submitter: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (!canCancel(po.status, session.user.role)) {
|
||||
return {
|
||||
error: po.status === "CANCELLED"
|
||||
? "This purchase order is already cancelled."
|
||||
: "You cannot cancel this purchase order.",
|
||||
};
|
||||
}
|
||||
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
cancelledAt: new Date(),
|
||||
cancellationReason: trimmed,
|
||||
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the submitter and Accounts (they track spend).
|
||||
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||
const recipients = [po.submitter, ...accounts].filter(
|
||||
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
|
||||
);
|
||||
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
|
||||
|
||||
revalidatePath(`/po/${poId}`);
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/history");
|
||||
revalidatePath("/my-orders");
|
||||
revalidatePath("/payments");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
|
||||
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
|
||||
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
|
||||
// surfaced on the replacement via the schema self-relation.
|
||||
|
||||
export async function supersedePo({
|
||||
poId,
|
||||
replacementPoNumber,
|
||||
}: {
|
||||
poId: string;
|
||||
replacementPoNumber: string;
|
||||
}): Promise<{ ok: true } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||
return { error: "You do not have permission to link a superseding purchase order." };
|
||||
}
|
||||
|
||||
const num = (replacementPoNumber ?? "").trim();
|
||||
if (!num) return { error: "Enter the PO number that supersedes this one." };
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (po.status !== "CANCELLED") {
|
||||
return { error: "Only a cancelled purchase order can be superseded." };
|
||||
}
|
||||
|
||||
const replacement = await db.purchaseOrder.findUnique({
|
||||
where: { poNumber: num },
|
||||
select: { id: true, poNumber: true },
|
||||
});
|
||||
if (!replacement) return { error: `No purchase order found with number "${num}".` };
|
||||
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
|
||||
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
supersededById: replacement.id,
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "SUPERSEDED",
|
||||
actorId: session.user.id,
|
||||
note: `Superseded by ${replacement.poNumber}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/po/${poId}`);
|
||||
revalidatePath(`/po/${replacement.id}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export default async function PoDetailPage({ params }: Props) {
|
|||
documents: { orderBy: { uploadedAt: "desc" } },
|
||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||
receipt: true,
|
||||
supersededBy: { select: { id: true, poNumber: true } },
|
||||
supersedes: { select: { id: true, poNumber: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import ExcelJS from "exceljs";
|
||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||
import { downloadBuffer } from "@/lib/storage";
|
||||
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||
|
||||
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
||||
|
||||
|
|
@ -23,6 +26,25 @@ function fmtNum(n: number, dec = 2): string {
|
|||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
|
@ -49,9 +71,11 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
|
||||
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
||||
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
||||
// The submitter's signature is never embedded; only the approving manager's signature is used.
|
||||
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
|
||||
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
|
||||
const isCancelled = po.status === "CANCELLED";
|
||||
if (!EXPORTABLE_STATUSES.includes(po.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Export is only available for approved purchase orders." },
|
||||
|
|
@ -110,6 +134,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
// Fetch approver's signature for embedding in the document
|
||||
let signatureBase64: string | null = null;
|
||||
let signatureMime = "image/png";
|
||||
let signatureSize: { width: number; height: number } | null = null;
|
||||
if (approvalAction) {
|
||||
const approver = await db.user.findUnique({
|
||||
where: { id: approvalAction.actorId },
|
||||
|
|
@ -121,10 +146,15 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
signatureBase64 = buf.toString("base64");
|
||||
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
|
||||
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 {
|
||||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||||
requisitionNo?: string | null; requisitionDate?: Date | null;
|
||||
|
|
@ -255,6 +285,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.mergeCells("A4:I4");
|
||||
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 ══════════════════════════════════════════════
|
||||
ws.getRow(5).height = 18;
|
||||
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
||||
|
|
@ -417,16 +460,47 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.getRow(SIG_ROW + 1).height = 14;
|
||||
ws.getRow(SIG_ROW + 2).height = 14;
|
||||
|
||||
// Left sig block (approver — the manager who authorized the PO)
|
||||
if (signatureBase64) {
|
||||
// Left signatory block (cols A-D). Position images by absolute pixels via native
|
||||
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels.
|
||||
const EMU = 9525; // EMU per pixel
|
||||
const COL_PX = [22, 4, 28, 15, 8, 15, 15, 8, 16].map((w) => Math.round(w * 7 + 5));
|
||||
const SIG_BLOCK_PX = COL_PX[0] + COL_PX[1] + COL_PX[2] + COL_PX[3]; // A-D
|
||||
const anchorAt = (leftPx: number, row: number) => {
|
||||
let x = 0;
|
||||
for (let c = 0; c < COL_PX.length - 1; c++) {
|
||||
if (leftPx < x + COL_PX[c]) {
|
||||
return { nativeCol: c, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||
}
|
||||
x += COL_PX[c];
|
||||
}
|
||||
return { nativeCol: COL_PX.length - 1, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||
};
|
||||
|
||||
const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null;
|
||||
const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null;
|
||||
// Signature centred over the name; stamp to its RIGHT with a gap (no overlap).
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt });
|
||||
|
||||
// Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch.
|
||||
if (stampImg && stampExt && stampLeft != null) {
|
||||
const stampId = wb.addImage({
|
||||
base64: stampImg.base64,
|
||||
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(stampId, {
|
||||
tl: anchorAt(stampLeft, SIG_ROW - 1),
|
||||
ext: stampExt,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
|
||||
if (signatureBase64 && sigExt && sigLeft != null) {
|
||||
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
|
||||
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
|
||||
// Span the image across columns A-D in the sig row
|
||||
ws.addImage(imgId, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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,
|
||||
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
|
||||
ext: sigExt,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
|
||||
|
|
@ -454,6 +528,27 @@ 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 });
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const slug = po.poNumber.replace(/\//g, "-");
|
||||
|
|
@ -506,9 +601,20 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
color: #111;
|
||||
margin: 10mm 12mm;
|
||||
line-height: 1.3;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header-band { position: relative; }
|
||||
.co-logo {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
max-height: 52px;
|
||||
max-width: 92px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.co-name {
|
||||
text-align: center;
|
||||
font-size: 13pt;
|
||||
|
|
@ -568,6 +674,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
/* ── Signatures ── */
|
||||
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||
.sig-box {
|
||||
position: relative;
|
||||
border: 1px solid #999;
|
||||
width: 44%;
|
||||
min-height: 60px;
|
||||
|
|
@ -579,9 +686,44 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
}
|
||||
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
||||
.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; }
|
||||
|
||||
/* ── 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 {
|
||||
.no-print { display: none; }
|
||||
body { margin: 8mm 10mm; }
|
||||
|
|
@ -591,6 +733,8 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</head>
|
||||
<body>
|
||||
|
||||
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
||||
|
||||
<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">
|
||||
🖨 Print / Save as PDF
|
||||
|
|
@ -598,9 +742,12 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
<div class="header-band">
|
||||
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
</div>
|
||||
<div class="po-title">PURCHASE ORDER</div>
|
||||
|
||||
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||
|
|
@ -718,6 +865,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||
<div class="sig">
|
||||
<div class="sig-box">
|
||||
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
||||
${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;" />`
|
||||
: `<div class="sig-name">${approvedBy}</div>`
|
||||
|
|
@ -725,7 +873,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<div>
|
||||
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
||||
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
||||
<div class="sig-sub">For, ${CO_NAME}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-box">
|
||||
|
|
@ -737,6 +885,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||
<div class="brand-bar"></div>
|
||||
|
||||
<script>window.onload = function() { window.print(); };</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const PO_STATUS_LABELS: Record<string, string> = {
|
|||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
|
||||
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
|
||||
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
158
App/components/po/cancel-po-controls.tsx
Normal file
158
App/components/po/cancel-po-controls.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||
|
||||
// ── Cancel PO button + confirmation modal ──────────────────────────────────────
|
||||
// The manager must type the word "cancel" and provide a reason before the action
|
||||
// is enabled — a deliberate friction step for an irreversible, terminal action.
|
||||
|
||||
export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const confirmed = confirmText.trim().toLowerCase() === "cancel";
|
||||
const canSubmit = confirmed && reason.trim().length > 0 && !pending;
|
||||
|
||||
function close() {
|
||||
if (pending) return;
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
setConfirmText("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!canSubmit) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await cancelPo({ poId, reason: reason.trim() });
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-lg bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 transition-colors"
|
||||
>
|
||||
Cancel PO
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={close}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Cancel {poNumber}?</h2>
|
||||
<p className="mt-1.5 text-sm text-neutral-600">
|
||||
This marks the purchase order as <strong>cancelled</strong> and removes its value from
|
||||
all spend trackers and graphs. This cannot be undone.
|
||||
</p>
|
||||
|
||||
<label className="mt-4 block text-xs font-medium text-neutral-700">
|
||||
Reason for cancellation <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
placeholder="e.g. Duplicate order — superseded by a corrected PO"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||
/>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium text-neutral-700">
|
||||
Type <span className="font-mono font-semibold">cancel</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="cancel"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||
/>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
disabled={pending}
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||
>
|
||||
Keep PO
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Cancelling…" : "Cancel this PO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Supersede: link a cancelled PO to the existing PO that replaces it ──────────
|
||||
|
||||
export function SupersedeForm({ poId }: { poId: string }) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleLink(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await supersedePo({ poId, replacementPoNumber: value.trim() });
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setValue("");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLink} className="mt-2 flex flex-wrap items-start gap-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Replacement PO number, e.g. PMS/HNR1/9001/2026-27"
|
||||
className="min-w-[260px] flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !value.trim()}
|
||||
className="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Linking…" : "Link replacement"}
|
||||
</button>
|
||||
{error && <p className="w-full text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { PoStatusBadge } from "@/components/po/po-status-badge";
|
|||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { DiscardDraftButton } from "@/components/po/discard-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 { generateDownloadUrl } from "@/lib/storage";
|
||||
import { groupAttachments } from "@/lib/attachments";
|
||||
|
|
@ -40,6 +41,10 @@ type PoWithRelations = {
|
|||
approvedAt: Date | null;
|
||||
paidAt: 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 };
|
||||
vessel: { id: string; name: string };
|
||||
account: { id: string; name: string; code: string };
|
||||
|
|
@ -92,6 +97,8 @@ const ACTION_LABELS: Record<string, string> = {
|
|||
CLOSED: "Closed",
|
||||
MANAGER_LINE_EDIT: "Manager amended line items",
|
||||
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
||||
CANCELLED: "Cancelled",
|
||||
SUPERSEDED: "Superseded",
|
||||
};
|
||||
|
||||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||
|
|
@ -203,8 +210,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
!readOnly && (
|
||||
<DiscardDraftButton poId={po.id} />
|
||||
)}
|
||||
{/* 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"].includes(po.status) && (<>
|
||||
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
|
||||
<a
|
||||
href={`/api/po/${po.id}/export?format=pdf`}
|
||||
target="_blank"
|
||||
|
|
@ -220,9 +227,59 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
Export XLSX
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
{po.managerNote && (
|
||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ const UOM_OPTIONS = [
|
|||
{ value: "mL", label: "mL — Millilitre" },
|
||||
{ value: "m", label: "m — Metre" },
|
||||
{ value: "m2", label: "m² — Sq. Metre" },
|
||||
{ value: "hr", label: "hr — Hour" },
|
||||
{ value: "day", label: "day — Day" },
|
||||
{ value: "hr", label: "hr — Hour" },
|
||||
{ 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: "Ltr", label: "Ltr — Litre (alt)" },
|
||||
];
|
||||
|
|
|
|||
6
App/lib/cancelled-watermark.ts
Normal file
6
App/lib/cancelled-watermark.ts
Normal file
File diff suppressed because one or more lines are too long
46
App/lib/image-size.ts
Normal file
46
App/lib/image-size.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Image dimension helpers used to size XLSX floating images by pixels with the
|
||||
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
|
||||
// an image to fill a cell range, which distorts logos / signatures / stamps.
|
||||
|
||||
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
|
||||
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
|
||||
// PNG — IHDR width/height at byte offsets 16 / 20
|
||||
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
// JPEG — scan segments for a Start-Of-Frame marker
|
||||
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
||||
let o = 2;
|
||||
while (o + 9 < buf.length) {
|
||||
if (buf[o] !== 0xff) { o++; continue; }
|
||||
const m = buf[o + 1];
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
||||
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
|
||||
}
|
||||
o += 2 + buf.readUInt16BE(o + 2);
|
||||
}
|
||||
}
|
||||
// WebP — RIFF container, VP8 / VP8L / VP8X
|
||||
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
|
||||
const fmt = buf.toString("ascii", 12, 16);
|
||||
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
||||
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
|
||||
if (fmt === "VP8X") {
|
||||
return {
|
||||
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
|
||||
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
|
||||
export function scaleToBox(
|
||||
natural: { width: number; height: number },
|
||||
maxW: number,
|
||||
maxH: number
|
||||
): { width: number; height: number } {
|
||||
const s = Math.min(maxW / natural.width, maxH / natural.height);
|
||||
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export type NotificationEvent =
|
|||
| "PO_APPROVED"
|
||||
| "PO_APPROVED_WITH_NOTE"
|
||||
| "PO_REJECTED"
|
||||
| "PO_CANCELLED"
|
||||
| "EDITS_REQUESTED"
|
||||
| "VENDOR_ID_REQUESTED"
|
||||
| "VENDOR_ID_PROVIDED"
|
||||
|
|
@ -119,6 +120,9 @@ function buildInAppBody(
|
|||
case "PO_REJECTED":
|
||||
return `${pn} rejected`;
|
||||
|
||||
case "PO_CANCELLED":
|
||||
return `${pn} has been cancelled`;
|
||||
|
||||
case "EDITS_REQUESTED":
|
||||
return `Edits requested on ${pn}`;
|
||||
|
||||
|
|
@ -215,6 +219,7 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
|
|||
PO_APPROVED: `${base} has been approved`,
|
||||
PO_APPROVED_WITH_NOTE: `${base} has been approved`,
|
||||
PO_REJECTED: `${base} has been rejected`,
|
||||
PO_CANCELLED: `${base} has been cancelled`,
|
||||
EDITS_REQUESTED: `Edits requested on ${base}`,
|
||||
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
|
||||
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
|
||||
|
|
@ -245,6 +250,8 @@ function buildEmailBody(
|
|||
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
|
||||
case "PO_REJECTED":
|
||||
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":
|
||||
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
|
||||
case "VENDOR_ID_REQUESTED":
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type Permission =
|
|||
| "view_all_pos"
|
||||
| "approve_po"
|
||||
| "reject_po"
|
||||
| "cancel_po"
|
||||
| "request_edits"
|
||||
| "request_vendor_id"
|
||||
| "process_payment"
|
||||
|
|
@ -33,6 +34,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_all_pos",
|
||||
"approve_po",
|
||||
"reject_po",
|
||||
"cancel_po",
|
||||
"request_edits",
|
||||
"request_vendor_id",
|
||||
"view_analytics",
|
||||
|
|
@ -53,6 +55,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_all_pos",
|
||||
"approve_po",
|
||||
"reject_po",
|
||||
"cancel_po",
|
||||
"request_edits",
|
||||
"request_vendor_id",
|
||||
"process_payment",
|
||||
|
|
|
|||
32
App/lib/po-export-layout.ts
Normal file
32
App/lib/po-export-layout.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Geometry for the exported PO's left signatory block (cols A-D).
|
||||
// The approver signature is centred over the name; the company stamp/seal sits to
|
||||
// its RIGHT with a gap so it never overlays the signature or name — important
|
||||
// because uploaded signatures/stamps aren't always transparent PNGs.
|
||||
|
||||
export interface Size { width: number; height: number }
|
||||
|
||||
export interface SignatoryLayout {
|
||||
sigLeft: number | null; // px from the block's left edge, or null when no signature
|
||||
stampLeft: number | null; // px from the block's left edge, or null when no stamp
|
||||
}
|
||||
|
||||
export function signatoryLayout(opts: {
|
||||
blockPx: number;
|
||||
sig: Size | null;
|
||||
stamp: Size | null;
|
||||
gap?: number;
|
||||
}): SignatoryLayout {
|
||||
const gap = opts.gap ?? 10;
|
||||
const sigLeft = opts.sig ? Math.round((opts.blockPx - opts.sig.width) / 2) : null; // centred
|
||||
|
||||
let stampLeft: number | null = null;
|
||||
if (opts.stamp) {
|
||||
stampLeft =
|
||||
sigLeft != null && opts.sig
|
||||
? Math.min(opts.blockPx - opts.stamp.width, sigLeft + opts.sig.width + gap) // clear of the signature
|
||||
: opts.blockPx - opts.stamp.width - 6; // no signature → right-align in the block
|
||||
stampLeft = Math.max(0, stampLeft);
|
||||
}
|
||||
|
||||
return { sigLeft, stampLeft };
|
||||
}
|
||||
|
|
@ -187,3 +187,15 @@ export function getAvailableActions(status: POStatus, role: Role): POAction[] {
|
|||
export function requiresNote(from: POStatus, action: POAction): boolean {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
|||
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).
|
||||
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,30 @@ 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 {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
|
|
@ -51,6 +75,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
|||
PAID_DELIVERED: "Paid",
|
||||
PARTIALLY_CLOSED: "Partially Received",
|
||||
CLOSED: "Closed",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
||||
|
|
@ -86,4 +111,5 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
|||
PAID_DELIVERED: "success",
|
||||
PARTIALLY_CLOSED: "warning",
|
||||
CLOSED: "secondary",
|
||||
CANCELLED: "danger",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
-- 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;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
-- Cancel + supersede: a new terminal CANCELLED status, cancel metadata, and a
|
||||
-- self-referential supersede link (cancelled PO -> the existing PO that replaces it).
|
||||
ALTER TYPE "POStatus" ADD VALUE 'CANCELLED';
|
||||
ALTER TYPE "ActionType" ADD VALUE 'CANCELLED';
|
||||
ALTER TYPE "ActionType" ADD VALUE 'SUPERSEDED';
|
||||
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancelledAt" TIMESTAMP(3);
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancellationReason" TEXT;
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "supersededById" TEXT;
|
||||
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supersededById_fkey"
|
||||
FOREIGN KEY ("supersededById") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -30,6 +30,7 @@ enum POStatus {
|
|||
PAID_DELIVERED
|
||||
PARTIALLY_CLOSED
|
||||
CLOSED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
|
|
@ -49,6 +50,8 @@ enum ActionType {
|
|||
REASSIGNED
|
||||
PRODUCT_PRICE_UPDATED
|
||||
MANAGER_LINE_EDIT
|
||||
CANCELLED
|
||||
SUPERSEDED
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
|
|
@ -125,6 +128,8 @@ model Company {
|
|||
email String?
|
||||
invoiceEmail 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)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -268,6 +273,8 @@ model PurchaseOrder {
|
|||
approvedAt DateTime?
|
||||
paidAt DateTime?
|
||||
closedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
cancellationReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
@ -284,6 +291,12 @@ model PurchaseOrder {
|
|||
siteId String?
|
||||
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[]
|
||||
documents PODocument[]
|
||||
actions POAction[]
|
||||
|
|
|
|||
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
Binary file not shown.
|
|
@ -32,7 +32,7 @@ beforeAll(async () => {
|
|||
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedVessel("MV Ocean Pride"),
|
||||
getSeedVessel("MV Poseidon"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
|
|
@ -49,10 +49,14 @@ afterEach(async () => {
|
|||
|
||||
// Helper: create a PO in MGR_REVIEW state
|
||||
async function createSubmittedPo(title: string): Promise<string> {
|
||||
vi.mocked(auth).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 result = await createPo(form);
|
||||
return (result as { id: string }).id;
|
||||
const id = (result as { id: string }).id;
|
||||
// Vendor gating: a vendor must be assigned before a PO can be approved.
|
||||
// Attach the seeded verified vendor directly (test setup) so approval-path tests run.
|
||||
await db.purchaseOrder.update({ where: { id }, data: { vendorId } });
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── M-02: Approve ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -60,7 +64,7 @@ async function createSubmittedPo(title: string): Promise<string> {
|
|||
describe("M-02 — approve PO", () => {
|
||||
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}Approve`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await approvePo({ poId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -72,7 +76,7 @@ describe("M-02 — approve PO", () => {
|
|||
|
||||
it("stores managerNote when approving with note", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
|
||||
|
||||
|
|
@ -88,7 +92,7 @@ describe("M-02 — approve PO", () => {
|
|||
const { notify } = await import("@/lib/notifier");
|
||||
vi.mocked(notify).mockClear();
|
||||
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
await approvePo({ poId });
|
||||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||||
|
|
@ -98,18 +102,18 @@ describe("M-02 — approve PO", () => {
|
|||
|
||||
it("returns error when TECHNICAL role tries to approve", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await approvePo({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns error when PO is not in MGR_REVIEW state", async () => {
|
||||
// Create a DRAFT PO, don't submit
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const result = await approvePo({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
|
@ -120,7 +124,7 @@ describe("M-02 — approve PO", () => {
|
|||
describe("M-03 — reject PO", () => {
|
||||
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}Reject`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -132,7 +136,7 @@ describe("M-03 — reject PO", () => {
|
|||
|
||||
it("creates a REJECTED action entry in the audit trail", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await rejectPo({ poId, note: "Not needed" });
|
||||
|
||||
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
|
||||
|
|
@ -143,7 +147,7 @@ describe("M-03 — reject PO", () => {
|
|||
const { notify } = await import("@/lib/notifier");
|
||||
vi.mocked(notify).mockClear();
|
||||
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
await rejectPo({ poId, note: "See notes" });
|
||||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||||
|
|
@ -157,7 +161,7 @@ describe("M-03 — reject PO", () => {
|
|||
describe("M-04 — request edits", () => {
|
||||
it("transitions PO to EDITS_REQUESTED with manager note", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}Edits`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await requestEdits({ poId, note: "Please add vendor ID" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -173,7 +177,7 @@ describe("M-04 — request edits", () => {
|
|||
describe("M-04 — request vendor ID", () => {
|
||||
it("transitions PO to VENDOR_ID_PENDING", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await requestVendorId({ poId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -188,10 +192,10 @@ describe("M-04 — request vendor ID", () => {
|
|||
describe("S-06 — provide vendor ID", () => {
|
||||
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await requestVendorId({ poId });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await provideVendorId({ poId, vendorId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
|
|
@ -242,7 +246,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const result = await approvePo({ poId: po.id });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
|
|
@ -285,7 +289,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId: po.id });
|
||||
|
||||
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
|
||||
|
|
@ -322,7 +326,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId: po.id });
|
||||
|
||||
const totalAfter = await db.itemInventory.count();
|
||||
|
|
@ -336,10 +340,10 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
|||
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
|
||||
const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await requestEdits({ poId, note: "Update line items" });
|
||||
|
||||
vi.mocked(auth).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 result = await updatePo(poId, form);
|
||||
expect(result).toEqual({ id: poId });
|
||||
|
|
@ -350,11 +354,11 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
|||
|
||||
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
|
||||
// Create a DRAFT PO
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "save" });
|
||||
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
||||
const result = await updatePo(poId, editForm);
|
||||
expect(result).toEqual({ id: poId });
|
||||
|
||||
|
|
|
|||
181
App/tests/integration/cancel-supersede.test.ts
Normal file
181
App/tests/integration/cancel-supersede.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Integration tests for PO cancellation and supersede linkage.
|
||||
* Covers: cancel from any state (MANAGER/SUPERUSER, reason required), exclusion
|
||||
* from spend aggregation, and linking a cancelled PO to an existing replacement.
|
||||
*
|
||||
* POs are built directly via db.create (not the makePoForm helper) so the test is
|
||||
* self-contained and cleans up cascade-safely (POAction has no onDelete: Cascade).
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor } from "./helpers";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const PREFIX = "INTTEST_CANCEL_";
|
||||
let techId: string;
|
||||
let managerId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
let seq = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedVessel("MV Galatea"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id; managerId = mgr.id;
|
||||
vesselId = vessel.id; accountId = account.id; vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const pos = await db.purchaseOrder.findMany({ where: { title: { startsWith: PREFIX } }, select: { id: true } });
|
||||
const ids = pos.map((p) => p.id);
|
||||
if (ids.length === 0) return;
|
||||
await db.purchaseOrder.updateMany({ where: { id: { in: ids } }, data: { supersededById: null } });
|
||||
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
|
||||
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
|
||||
});
|
||||
|
||||
async function makePo(label: string, status: POStatus): Promise<string> {
|
||||
seq += 1;
|
||||
const po = await db.purchaseOrder.create({
|
||||
data: {
|
||||
poNumber: `CANCELTEST-${seq}-${label}`,
|
||||
title: `${PREFIX}${label}`,
|
||||
status,
|
||||
totalAmount: 1180,
|
||||
currency: "INR",
|
||||
vesselId,
|
||||
accountId,
|
||||
submitterId: techId,
|
||||
...(status === "MGR_APPROVED" ? { vendorId, approvedAt: new Date() } : {}),
|
||||
lineItems: { create: [{ name: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, totalPrice: 1180, gstRate: 0.18, sortOrder: 0 }] },
|
||||
actions: { create: { actionType: "CREATED", actorId: techId } },
|
||||
},
|
||||
});
|
||||
return po.id;
|
||||
}
|
||||
|
||||
describe("cancelPo", () => {
|
||||
it("cancels a DRAFT PO with a reason and writes an audit row", async () => {
|
||||
const poId = await makePo("Draft", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
|
||||
const result = await cancelPo({ poId, reason: "Duplicate order" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("CANCELLED");
|
||||
expect(po.cancelledAt).not.toBeNull();
|
||||
expect(po.cancellationReason).toBe("Duplicate order");
|
||||
|
||||
const action = await db.pOAction.findFirst({ where: { poId, actionType: "CANCELLED" } });
|
||||
expect(action?.note).toBe("Duplicate order");
|
||||
});
|
||||
|
||||
it("cancels an already-APPROVED PO (cancellable from any state)", async () => {
|
||||
const poId = await makePo("Approved", "MGR_APPROVED");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await cancelPo({ poId, reason: "Vendor backed out" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("CANCELLED");
|
||||
});
|
||||
|
||||
it("a cancelled PO drops out of the spend aggregation filter", async () => {
|
||||
const poId = await makePo("Spend", "MGR_APPROVED");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "Excluded from spend" });
|
||||
|
||||
expect(POST_APPROVAL_STATUSES as readonly string[]).not.toContain("CANCELLED");
|
||||
const stillCounted = await db.purchaseOrder.findFirst({
|
||||
where: { id: poId, status: { in: [...POST_APPROVAL_STATUSES] } },
|
||||
});
|
||||
expect(stillCounted).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a reason", async () => {
|
||||
const poId = await makePo("NoReason", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await cancelPo({ poId, reason: " " });
|
||||
expect(result).toEqual({ error: "A cancellation reason is required." });
|
||||
});
|
||||
|
||||
it("refuses a role without cancel_po (TECHNICAL)", async () => {
|
||||
const poId = await makePo("Forbidden", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(techId, "TECHNICAL") as never);
|
||||
const result = await cancelPo({ poId, reason: "nope" });
|
||||
expect(result).toHaveProperty("error");
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("refuses to cancel an already-cancelled PO", async () => {
|
||||
const poId = await makePo("Twice", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "first" });
|
||||
const result = await cancelPo({ poId, reason: "second" });
|
||||
expect(result).toEqual({ error: "This purchase order is already cancelled." });
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersedePo", () => {
|
||||
it("links a cancelled PO to an existing replacement (reciprocal)", async () => {
|
||||
const cancelledId = await makePo("Old", "DRAFT");
|
||||
const replacementId = await makePo("New", "DRAFT");
|
||||
const replacement = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId } });
|
||||
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId: cancelledId, reason: "Replaced" });
|
||||
const result = await supersedePo({ poId: cancelledId, replacementPoNumber: replacement.poNumber });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const old = await db.purchaseOrder.findUniqueOrThrow({ where: { id: cancelledId } });
|
||||
expect(old.supersededById).toBe(replacementId);
|
||||
|
||||
const repl = await db.purchaseOrder.findUniqueOrThrow({
|
||||
where: { id: replacementId },
|
||||
include: { supersedes: { select: { id: true } } },
|
||||
});
|
||||
expect(repl.supersedes.map((s) => s.id)).toContain(cancelledId);
|
||||
});
|
||||
|
||||
it("refuses to supersede a PO that is not cancelled", async () => {
|
||||
const poId = await makePo("NotCancelled", "DRAFT");
|
||||
const otherId = await makePo("Other", "DRAFT");
|
||||
const other = await db.purchaseOrder.findUniqueOrThrow({ where: { id: otherId } });
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await supersedePo({ poId, replacementPoNumber: other.poNumber });
|
||||
expect(result).toEqual({ error: "Only a cancelled purchase order can be superseded." });
|
||||
});
|
||||
|
||||
it("rejects an unknown replacement PO number", async () => {
|
||||
const poId = await makePo("Unknown", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "x" });
|
||||
const result = await supersedePo({ poId, replacementPoNumber: "PMS/ZZZ/0000/2000-01" });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects self-supersede", async () => {
|
||||
const poId = await makePo("Self", "DRAFT");
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "x" });
|
||||
const result = await supersedePo({ poId, replacementPoNumber: po.poNumber });
|
||||
expect(result).toEqual({ error: "A purchase order cannot supersede itself." });
|
||||
});
|
||||
});
|
||||
114
App/tests/integration/company-branding.test.ts
Normal file
114
App/tests/integration/company-branding.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* 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" });
|
||||
});
|
||||
});
|
||||
84
App/tests/integration/company-crud.test.ts
Normal file
84
App/tests/integration/company-crud.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getSeedUser,
|
||||
getSeedVessel,
|
||||
getSeedAccount,
|
||||
getSeedVendor,
|
||||
makePoForm,
|
||||
deletePosByTitle,
|
||||
} from "./helpers";
|
||||
|
|
@ -32,20 +33,23 @@ let managerId: string;
|
|||
let accountsId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Sea Breeze"),
|
||||
getSeedVessel("MV Nereid"),
|
||||
getSeedAccount("700202"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
accountsId = acct.id;
|
||||
vesselId = vessel.id;
|
||||
accountId = account.id;
|
||||
vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -54,18 +58,20 @@ afterEach(async () => {
|
|||
|
||||
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
|
||||
async function createPaidPo(title: string): Promise<string> {
|
||||
vi.mocked(auth).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 { 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).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
return poId;
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +173,7 @@ describe("confirmReceipt — permission guards", () => {
|
|||
const otherTech = await getSeedUser("tech@pelagia.local");
|
||||
// Use a different user id to simulate a different submitter
|
||||
const fakeSession = makeSession(managerId, "TECHNICAL");
|
||||
vi.mocked(auth).mockResolvedValue(fakeSession);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(fakeSession);
|
||||
|
||||
const result = await confirmReceipt({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
|
|
@ -176,7 +182,7 @@ describe("confirmReceipt — permission guards", () => {
|
|||
|
||||
it("rejects confirmation on a PO in wrong status", async () => {
|
||||
// Create a PO that is still DRAFT (no payment yet)
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
|
|
@ -190,7 +196,7 @@ describe("confirmReceipt — permission guards", () => {
|
|||
});
|
||||
|
||||
it("returns error when not authenticated", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
const result = await confirmReceipt({ poId: "any-id" });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ let vendorId: string;
|
|||
beforeAll(async () => {
|
||||
const [tech, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedVessel("MV Ocean Pride"),
|
||||
getSeedVessel("MV Aegean Wind"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
|
|
@ -43,7 +43,7 @@ afterEach(async () => {
|
|||
|
||||
describe("S-02 — save as draft", () => {
|
||||
it("creates a PO in DRAFT status", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const form = makePoForm({
|
||||
title: `${PREFIX}Draft`,
|
||||
|
|
@ -59,7 +59,7 @@ describe("S-02 — save as draft", () => {
|
|||
});
|
||||
|
||||
it("returns error for unauthenticated request", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
||||
const result = await createPo(form);
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
|
|
@ -67,19 +67,19 @@ describe("S-02 — save as draft", () => {
|
|||
|
||||
it("returns error when ACCOUNTS role tries to create a PO", async () => {
|
||||
const acct = await getSeedUser("accounts@pelagia.local");
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
|
||||
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
|
||||
const result = await createPo(form);
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns error when a required field (vesselId) is missing", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = new FormData();
|
||||
form.set("title", `${PREFIX}NoVessel`);
|
||||
form.set("accountId", accountId);
|
||||
form.set("intent", "draft");
|
||||
form.set("lineItems[0].description", "Item");
|
||||
form.set("lineItems[0].name", "Item");
|
||||
form.set("lineItems[0].quantity", "1");
|
||||
form.set("lineItems[0].unit", "pc");
|
||||
form.set("lineItems[0].unitPrice", "50");
|
||||
|
|
@ -93,7 +93,7 @@ describe("S-02 — save as draft", () => {
|
|||
|
||||
describe("S-01 — create PO with line items", () => {
|
||||
it("stores line items with correct quantity, unit price, and GST rate", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const form = makePoForm({
|
||||
title: `${PREFIX}LineItems`,
|
||||
|
|
@ -120,7 +120,7 @@ describe("S-01 — create PO with line items", () => {
|
|||
});
|
||||
|
||||
it("sets totalAmount to grand total including GST", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
// 10 × 100 × 1.18 = 1180
|
||||
const form = makePoForm({
|
||||
|
|
@ -135,7 +135,7 @@ describe("S-01 — create PO with line items", () => {
|
|||
});
|
||||
|
||||
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
|
||||
form.set("piQuotationNo", "Verbal");
|
||||
|
|
@ -154,7 +154,7 @@ describe("S-01 — create PO with line items", () => {
|
|||
|
||||
it("allows MANNING role to create a PO", async () => {
|
||||
const manning = await getSeedUser("manning@pelagia.local");
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(manning.id, "MANNING"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(manning.id, "MANNING"));
|
||||
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
|
||||
const result = await createPo(form);
|
||||
expect(result).not.toHaveProperty("error");
|
||||
|
|
@ -165,7 +165,7 @@ describe("S-01 — create PO with line items", () => {
|
|||
|
||||
describe("S-03 — submit for approval", () => {
|
||||
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
||||
const result = await createPo(form);
|
||||
|
|
@ -180,7 +180,7 @@ describe("S-03 — submit for approval", () => {
|
|||
it("sends notification to managers on submit", async () => {
|
||||
const { notify } = await import("@/lib/notifier");
|
||||
vi.mocked(notify).mockClear();
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
|
||||
await createPo(form);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
|
|
@ -44,7 +44,7 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(asUserId, asRole));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(asUserId, asRole));
|
||||
const form = makePoForm({ title, vesselId, accountId, intent: "draft" });
|
||||
const result = await createPo(form);
|
||||
return (result as { id: string }).id;
|
||||
|
|
@ -55,7 +55,7 @@ async function createDraft(title: string, asUserId = techId, asRole: Parameters<
|
|||
describe("discard — happy path", () => {
|
||||
it("owner (TECHNICAL) can discard their own DRAFT", async () => {
|
||||
const poId = await createDraft(`${PREFIX}OwnerDiscard`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -64,7 +64,7 @@ describe("discard — happy path", () => {
|
|||
|
||||
it("MANAGER can discard any DRAFT PO (not their own)", async () => {
|
||||
const poId = await createDraft(`${PREFIX}MgrDiscard`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -74,7 +74,7 @@ describe("discard — happy path", () => {
|
|||
it("SUPERUSER can discard any DRAFT PO", async () => {
|
||||
const superuser = await getSeedUser("admin@pelagia.local");
|
||||
const poId = await createDraft(`${PREFIX}SuperDiscard`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
|
||||
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -87,7 +87,7 @@ describe("discard — happy path", () => {
|
|||
const before = await db.pOAction.findMany({ where: { poId } });
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
await discardDraftPo(poId);
|
||||
|
||||
const after = await db.pOAction.findMany({ where: { poId } });
|
||||
|
|
@ -99,7 +99,7 @@ describe("discard — happy path", () => {
|
|||
const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
|
||||
expect(linesBefore.length).toBeGreaterThan(0);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
await discardDraftPo(poId);
|
||||
|
||||
const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
|
||||
|
|
@ -112,7 +112,7 @@ describe("discard — happy path", () => {
|
|||
describe("discard — negative / permission tests", () => {
|
||||
it("returns error for unauthenticated request", async () => {
|
||||
const poId = await createDraft(`${PREFIX}Unauth`);
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
expect(await discardDraftPo(poId)).toHaveProperty("error");
|
||||
});
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ describe("discard — negative / permission tests", () => {
|
|||
// Create PO as manager, try to discard as tech
|
||||
const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER");
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toHaveProperty("error");
|
||||
// PO must still exist
|
||||
|
|
@ -129,14 +129,14 @@ describe("discard — negative / permission tests", () => {
|
|||
|
||||
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
|
||||
const poId = await createDraft(`${PREFIX}AccountsForbidden`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toHaveProperty("error");
|
||||
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns error for non-existent PO", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await discardDraftPo("non-existent-id");
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
|
@ -146,11 +146,11 @@ describe("discard — negative / permission tests", () => {
|
|||
|
||||
describe("discard — status guard", () => {
|
||||
it("cannot discard a submitted (MGR_REVIEW) PO", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const result = await discardDraftPo(poId);
|
||||
expect(result).toHaveProperty("error");
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function appendLineItem(
|
|||
idx: number,
|
||||
item: { description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }
|
||||
) {
|
||||
form.set(`lineItems[${idx}].description`, item.description);
|
||||
form.set(`lineItems[${idx}].name`, item.description);
|
||||
form.set(`lineItems[${idx}].quantity`, String(item.quantity));
|
||||
form.set(`lineItems[${idx}].unit`, item.unit);
|
||||
form.set(`lineItems[${idx}].unitPrice`, String(item.unitPrice));
|
||||
|
|
@ -58,7 +58,7 @@ export function makePoForm(overrides: {
|
|||
vesselId: string;
|
||||
accountId: string;
|
||||
vendorId?: string;
|
||||
intent?: "draft" | "submit";
|
||||
intent?: "draft" | "submit" | "resubmit";
|
||||
lineItems?: Array<{ description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }>;
|
||||
}): FormData {
|
||||
const form = new FormData();
|
||||
|
|
@ -76,12 +76,23 @@ export function makePoForm(overrides: {
|
|||
|
||||
// ── 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) {
|
||||
await db.purchaseOrder.delete({ where: { id: poId } }).catch(() => {});
|
||||
await deletePosByIds([poId]).catch(() => {});
|
||||
}
|
||||
|
||||
export async function deletePosByTitle(titlePrefix: string) {
|
||||
await db.purchaseOrder.deleteMany({
|
||||
const pos = await db.purchaseOrder.findMany({
|
||||
where: { title: { startsWith: titlePrefix } },
|
||||
select: { id: true },
|
||||
});
|
||||
await deletePosByIds(pos.map((p) => p.id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx
|
||||
* fixture using the real route handler.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ import { POST } from "@/app/api/po/import/route";
|
|||
import { makeSession, getSeedUser } from "./helpers";
|
||||
import type { ParsedImport } from "@/lib/po-import-parser";
|
||||
|
||||
const SAMPLE_XLSX = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
||||
const SAMPLE_XLSX = resolve(__dirname, "../fixtures/Sample_PO.xlsx");
|
||||
|
||||
let techId: string;
|
||||
let managerId: string;
|
||||
|
|
@ -50,13 +50,13 @@ function makeFileRequest(filePath?: string) {
|
|||
|
||||
describe("POST /api/po/import — authorization", () => {
|
||||
it("returns 401 for unauthenticated requests", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 for TECHNICAL role", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
|
|
@ -64,13 +64,13 @@ describe("POST /api/po/import — authorization", () => {
|
|||
});
|
||||
|
||||
it("returns 403 for ACCOUNTS role", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 200 for MANAGER role with valid file", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
|
@ -80,7 +80,7 @@ describe("POST /api/po/import — authorization", () => {
|
|||
|
||||
describe("POST /api/po/import — input validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
});
|
||||
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
|
|
@ -106,7 +106,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
|||
let results: ParsedImport[];
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||
const data = await res.json();
|
||||
results = data.results;
|
||||
|
|
@ -120,9 +120,9 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
|||
const items = results[0].lineItems;
|
||||
const hasTcText = items.some(
|
||||
(li) =>
|
||||
li.description.toLowerCase().includes("please quote") ||
|
||||
li.description.toLowerCase().includes("delivery :") ||
|
||||
li.description.toLowerCase().includes("payment terms")
|
||||
li.name.toLowerCase().includes("please quote") ||
|
||||
li.name.toLowerCase().includes("delivery :") ||
|
||||
li.name.toLowerCase().includes("payment terms")
|
||||
);
|
||||
expect(hasTcText).toBe(false);
|
||||
});
|
||||
|
|
@ -132,7 +132,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
|||
});
|
||||
|
||||
it("line item has correct description", () => {
|
||||
expect(results[0].lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL");
|
||||
expect(results[0].lineItems[0].name).toBe("Eni EP 80W90 GEAR OIL");
|
||||
});
|
||||
|
||||
it("line item has correct quantity (1050)", () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
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 { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
|
||||
import {
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
managerId = mgr.id;
|
||||
|
|
@ -48,7 +48,7 @@ afterEach(async () => {
|
|||
|
||||
describe("MANAGER — create PO", () => {
|
||||
it("MANAGER can save a PO as DRAFT", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" });
|
||||
|
||||
const result = await createPo(form);
|
||||
|
|
@ -59,7 +59,7 @@ describe("MANAGER — create PO", () => {
|
|||
});
|
||||
|
||||
it("MANAGER can submit a PO directly", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
||||
|
||||
const result = await createPo(form);
|
||||
|
|
@ -70,7 +70,7 @@ describe("MANAGER — create PO", () => {
|
|||
});
|
||||
|
||||
it("MANAGER can discard their own DRAFT", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ describe("MANAGER — create PO", () => {
|
|||
});
|
||||
|
||||
it("stores correct submitterId on MANAGER-created PO", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
|
|
@ -92,14 +92,14 @@ describe("MANAGER — create PO", () => {
|
|||
|
||||
describe("role — negative permission tests for PO creation", () => {
|
||||
it("ACCOUNTS cannot create a PO", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId });
|
||||
const result = await createPo(form);
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("unauthenticated request returns Unauthorized", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
||||
const result = await createPo(form);
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
|
|
@ -107,7 +107,7 @@ describe("role — negative permission tests for PO creation", () => {
|
|||
|
||||
it("MANAGER cannot approve their own submitted PO (same user)", async () => {
|
||||
// Manager creates and submits
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const form = makePoForm({
|
||||
title: `${PREFIX}SelfApprove`,
|
||||
vesselId,
|
||||
|
|
@ -120,7 +120,7 @@ describe("role — negative permission tests for PO creation", () => {
|
|||
// Approving as the same manager — the action itself doesn't block same-user approval
|
||||
// because approval authority is role-based, not submitter-based.
|
||||
// This test documents the current behaviour.
|
||||
const result = await approvepo({ poId });
|
||||
const result = await approvePo({ poId });
|
||||
// Should succeed because MANAGER has approve_po permission and the PO has a vendor
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createPo } from "@/app/(portal)/po/new/actions";
|
|||
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
||||
import {
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount,
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||
makePoForm, deletePosByTitle,
|
||||
} from "./helpers";
|
||||
|
||||
|
|
@ -25,20 +25,23 @@ let managerId: string;
|
|||
let accountsId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Sea Breeze"),
|
||||
getSeedVessel("MV Thetis"),
|
||||
getSeedAccount("700202"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
accountsId = acct.id;
|
||||
vesselId = vessel.id;
|
||||
accountId = account.id;
|
||||
vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -47,11 +50,13 @@ afterEach(async () => {
|
|||
|
||||
// Helper: create PO → submit → approve (reaches MGR_APPROVED)
|
||||
async function createApprovedPo(title: string): Promise<string> {
|
||||
vi.mocked(auth).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 { 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).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId });
|
||||
return poId;
|
||||
}
|
||||
|
|
@ -67,7 +72,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
|
|||
|
||||
it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}ProcessPayment`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
|
||||
const result = await processPayment({ poId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -78,7 +83,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
|
|||
|
||||
it("TECHNICAL role cannot process payment", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await processPayment({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
|
@ -90,7 +95,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}MarkPaid`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -105,7 +110,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
it("creates a PAYMENT_SENT action in the audit trail", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidAudit`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
|
||||
|
||||
|
|
@ -117,7 +122,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
it("returns error when paymentRef is missing", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidNoRef`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
|
||||
expect(result).toHaveProperty("error");
|
||||
|
|
@ -126,7 +131,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
it("returns error when payment date is in the future", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
|
||||
|
|
@ -137,7 +142,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
const { notify } = await import("@/lib/notifier");
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidNotify`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(notify).mockClear();
|
||||
await processPayment({ poId });
|
||||
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
|
||||
|
|
@ -146,14 +151,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
expect(calls).toContain("PAYMENT_SENT");
|
||||
});
|
||||
|
||||
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
|
||||
it("TECHNICAL role cannot mark as paid (no process_payment permission)", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidTechForbidden`);
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await markPaid({ poId, paymentRef: "TECH-REF", paymentDate: TODAY });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Integration tests for GET /api/products/search.
|
||||
* Tests authorization, query validation, filtering, and Decimal serialisation.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
|
|
@ -31,19 +31,19 @@ function makeRequest(query: string) {
|
|||
|
||||
describe("GET /api/products/search — authorization", () => {
|
||||
it("returns 401 for unauthenticated requests", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||
const res = await GET(makeRequest("oil"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("TECHNICAL can search products", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const res = await GET(makeRequest("oil"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("ACCOUNTS can search products", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
const res = await GET(makeRequest("oil"));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
|
@ -53,7 +53,7 @@ describe("GET /api/products/search — authorization", () => {
|
|||
|
||||
describe("GET /api/products/search — query validation", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
});
|
||||
|
||||
it("returns empty array for query shorter than 2 chars", async () => {
|
||||
|
|
@ -79,7 +79,7 @@ describe("GET /api/products/search — query validation", () => {
|
|||
|
||||
describe("GET /api/products/search — search behaviour", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
});
|
||||
|
||||
it("finds products by name substring", async () => {
|
||||
|
|
@ -91,7 +91,8 @@ describe("GET /api/products/search — search behaviour", () => {
|
|||
it("finds products by product code", async () => {
|
||||
const res = await GET(makeRequest("LUBE"));
|
||||
const data: { code: string }[] = await res.json();
|
||||
expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
||||
// search spans code/name/description, so assert the code matches are present (not that every hit is a code match)
|
||||
expect(data.some((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
||||
});
|
||||
|
||||
it("finds products by description text", async () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* - Unverified vendor rejected by provideVendorId
|
||||
* - AUDITOR cannot provide vendor ID
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
|
@ -16,7 +16,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||
import { approvepo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions";
|
||||
import { approvePo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions";
|
||||
import { provideVendorId } from "@/app/(portal)/po/[id]/actions";
|
||||
import {
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||
|
|
@ -39,7 +39,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
|
|
@ -66,17 +66,24 @@ beforeAll(async () => {
|
|||
auditorId = created.id;
|
||||
}
|
||||
|
||||
// Grab an unverified vendor
|
||||
const unverified = await db.vendor.findFirst({ where: { isVerified: false } });
|
||||
unverifiedVendorDbId = unverified!.id;
|
||||
// A vendor with no formal vendorId code — provideVendorId must reject it.
|
||||
// (Seeded "unverified" vendors can still carry a code, so create a code-less one.)
|
||||
const noCode = await db.vendor.create({
|
||||
data: { name: `${PREFIX}NoCodeVendor`, isVerified: false, vendorId: null },
|
||||
});
|
||||
unverifiedVendorDbId = noCode.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deletePosByTitle(PREFIX);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||
});
|
||||
|
||||
async function makeReviewPo(title: string, withVendor = false) {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({
|
||||
title,
|
||||
vesselId,
|
||||
|
|
@ -93,9 +100,9 @@ async function makeReviewPo(title: string, withVendor = false) {
|
|||
describe("approval — vendor required", () => {
|
||||
it("blocks approval when PO has no vendor assigned", async () => {
|
||||
const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await approvepo({ poId });
|
||||
const result = await approvePo({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
expect((result as { error: string }).error).toMatch(/vendor/i);
|
||||
|
||||
|
|
@ -105,9 +112,9 @@ describe("approval — vendor required", () => {
|
|||
|
||||
it("allows approval when PO has a vendor assigned", async () => {
|
||||
const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
const result = await approvepo({ poId });
|
||||
const result = await approvePo({ poId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
|
|
@ -120,14 +127,14 @@ describe("approval — vendor required", () => {
|
|||
describe("provideVendorId — role expansion", () => {
|
||||
async function makePendingPo(title: string) {
|
||||
const poId = await makeReviewPo(title);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await requestVendorId({ poId });
|
||||
return poId;
|
||||
}
|
||||
|
||||
it("ACCOUNTS can provide a verified vendor ID", async () => {
|
||||
const poId = await makePendingPo(`${PREFIX}AccountsProvide`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
|
||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
|
@ -139,7 +146,7 @@ describe("provideVendorId — role expansion", () => {
|
|||
|
||||
it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => {
|
||||
const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
|
||||
const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId });
|
||||
expect(result).toHaveProperty("error");
|
||||
|
|
@ -150,7 +157,7 @@ describe("provideVendorId — role expansion", () => {
|
|||
|
||||
it("AUDITOR cannot provide vendor ID", async () => {
|
||||
const poId = await makePendingPo(`${PREFIX}AuditorDenied`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
|
||||
|
||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||
expect(result).toHaveProperty("error");
|
||||
|
|
@ -159,7 +166,7 @@ describe("provideVendorId — role expansion", () => {
|
|||
it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => {
|
||||
// PO still in MGR_REVIEW — no requestVendorId called
|
||||
const poId = await makeReviewPo(`${PREFIX}WrongState`);
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
|
||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||
expect(result).toHaveProperty("error");
|
||||
|
|
|
|||
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
|
||||
vi.mock("@/app/(portal)/po/[id]/actions", () => ({ cancelPo: vi.fn(), supersedePo: vi.fn() }));
|
||||
|
||||
import { CancelPoButton } from "@/components/po/cancel-po-controls";
|
||||
|
||||
// Regression guard: the theme only defines danger / -50 / -100 / -700, so an
|
||||
// undefined shade like bg-danger-600 renders no background → the button was
|
||||
// invisible (white text on nothing). Both cancel buttons must use `bg-danger`.
|
||||
|
||||
describe("CancelPoButton", () => {
|
||||
it("renders the trigger as a filled red (bg-danger) button with white text", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
const btn = screen.getByRole("button", { name: "Cancel PO" });
|
||||
// standalone `bg-danger` (a defined token), NOT `bg-danger-600` (undefined → invisible)
|
||||
expect(btn.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(btn.className).toContain("text-white");
|
||||
});
|
||||
|
||||
it("opens a modal whose confirm button is a visible filled danger button", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" });
|
||||
expect(confirm.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(confirm.className).toContain("text-white");
|
||||
|
||||
// Keep PO is always present as the safe default.
|
||||
expect(screen.getByRole("button", { name: "Keep PO" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the confirm action disabled until 'cancel' is typed and a reason given", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" }) as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Duplicate order/i), { target: { value: "No longer needed" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("cancel"), { target: { value: "cancel" } });
|
||||
expect(confirm.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
55
App/tests/unit/image-size.test.ts
Normal file
55
App/tests/unit/image-size.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||
|
||||
function fakePng(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(24);
|
||||
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47; // PNG signature start
|
||||
b.writeUInt32BE(width, 16);
|
||||
b.writeUInt32BE(height, 20);
|
||||
return b;
|
||||
}
|
||||
|
||||
function fakeJpeg(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(20);
|
||||
b[0] = 0xff; b[1] = 0xd8; // SOI
|
||||
b[2] = 0xff; b[3] = 0xc0; // SOF0 marker
|
||||
b.writeUInt16BE(0x11, 4); // segment length
|
||||
b[6] = 8; // precision
|
||||
b.writeUInt16BE(height, 7);
|
||||
b.writeUInt16BE(width, 9);
|
||||
return b;
|
||||
}
|
||||
|
||||
describe("getImageSize", () => {
|
||||
it("reads PNG dimensions", () => {
|
||||
expect(getImageSize(fakePng(640, 480))).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("reads JPEG dimensions from the SOF marker", () => {
|
||||
expect(getImageSize(fakeJpeg(1024, 768))).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it("returns null for non-image data", () => {
|
||||
expect(getImageSize(Buffer.from("not an image at all"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleToBox", () => {
|
||||
it("preserves a square aspect ratio (downscale by the binding side)", () => {
|
||||
const r = scaleToBox({ width: 200, height: 200 }, 96, 52);
|
||||
expect(r.width).toBe(r.height); // stays square — never stretched
|
||||
expect(r.height).toBeLessThanOrEqual(52);
|
||||
});
|
||||
|
||||
it("fits a wide image to the width and keeps the ratio", () => {
|
||||
const r = scaleToBox({ width: 360, height: 96 }, 165, 44);
|
||||
expect(r.width).toBeLessThanOrEqual(165);
|
||||
expect(r.height).toBeLessThanOrEqual(44);
|
||||
expect(r.width / r.height).toBeCloseTo(360 / 96, 1);
|
||||
});
|
||||
|
||||
it("keeps the watermark's landscape ratio", () => {
|
||||
const r = scaleToBox({ width: 1400, height: 1000 }, 880, 720);
|
||||
expect(r).toEqual({ width: 880, height: 629 });
|
||||
});
|
||||
});
|
||||
|
|
@ -17,8 +17,10 @@ describe("Permissions", () => {
|
|||
expect(hasPermission("MANAGER", "approve_po")).toBe(true);
|
||||
});
|
||||
|
||||
it("MANAGER cannot process payment", () => {
|
||||
expect(hasPermission("MANAGER", "process_payment")).toBe(false);
|
||||
// MANAGER was intentionally granted process_payment in commit e1340b9
|
||||
// ("chore(perm): manager permissions fix 2").
|
||||
it("MANAGER can process payment", () => {
|
||||
expect(hasPermission("MANAGER", "process_payment")).toBe(true);
|
||||
});
|
||||
|
||||
it("ACCOUNTS can process payment", () => {
|
||||
|
|
|
|||
39
App/tests/unit/po-export-layout.test.ts
Normal file
39
App/tests/unit/po-export-layout.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||
|
||||
const BLOCK = 503; // px width of the A-D signatory block
|
||||
|
||||
describe("signatoryLayout", () => {
|
||||
it("centres the signature in the block", () => {
|
||||
const { sigLeft } = signatoryLayout({ blockPx: BLOCK, sig: { width: 153, height: 44 }, stamp: null });
|
||||
expect(sigLeft).not.toBeNull();
|
||||
expect(sigLeft! + 153 / 2).toBeCloseTo(BLOCK / 2, 0); // centre ≈ block centre
|
||||
});
|
||||
|
||||
it("places the stamp to the RIGHT of the signature with no overlap", () => {
|
||||
const sig = { width: 153, height: 44 };
|
||||
const stamp = { width: 67, height: 66 };
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp, gap: 10 });
|
||||
expect(stampLeft! ).toBeGreaterThanOrEqual(sigLeft! + sig.width); // starts at/after signature ends
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); // stays inside the block
|
||||
});
|
||||
|
||||
it("never overlaps even with the widest signature + stamp", () => {
|
||||
const sig = { width: 165, height: 44 }; // scaleToBox caps
|
||||
const stamp = { width: 80, height: 66 }; // scaleToBox caps
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp });
|
||||
expect(stampLeft!).toBeGreaterThanOrEqual(sigLeft! + sig.width);
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK);
|
||||
});
|
||||
|
||||
it("right-aligns the stamp when there is no signature", () => {
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig: null, stamp: { width: 67, height: 66 } });
|
||||
expect(sigLeft).toBeNull();
|
||||
expect(stampLeft! + 67).toBeLessThanOrEqual(BLOCK);
|
||||
expect(stampLeft!).toBeGreaterThan(BLOCK / 2); // on the right side
|
||||
});
|
||||
|
||||
it("returns nulls when there are no images", () => {
|
||||
expect(signatoryLayout({ blockPx: BLOCK, sig: null, stamp: null })).toEqual({ sigLeft: null, stampLeft: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -3,13 +3,17 @@
|
|||
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
|
||||
* workbooks built in-memory, without any HTTP or database layer.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import * as XLSX from "xlsx";
|
||||
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
|
||||
|
||||
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
||||
// The original Sample_PO.xlsx lives outside the repo, so these fixture-backed
|
||||
// tests skip wherever the file is absent (CI, other machines). The synthetic
|
||||
// workbook tests below exercise the parser everywhere.
|
||||
const HAS_SAMPLE = existsSync(SAMPLE_PATH);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -77,7 +81,7 @@ describe("cellNum", () => {
|
|||
|
||||
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
|
||||
|
||||
describe("parseSheet — Sample_PO.xlsx", () => {
|
||||
describe.skipIf(!HAS_SAMPLE)("parseSheet — Sample_PO.xlsx", () => {
|
||||
let parsed: ReturnType<typeof parseSheet>;
|
||||
|
||||
beforeAll(() => {
|
||||
|
|
@ -248,7 +252,7 @@ describe("parseSheet — synthetic edge cases", () => {
|
|||
// ── parseWorkbook ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseWorkbook", () => {
|
||||
it("parses the real Sample_PO.xlsx and returns one result", () => {
|
||||
it.skipIf(!HAS_SAMPLE)("parses the real Sample_PO.xlsx and returns one result", () => {
|
||||
const buffer = readFileSync(SAMPLE_PATH);
|
||||
const results = parseWorkbook(buffer);
|
||||
expect(results).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,25 @@ describe("LineItemsEditor — edit mode", () => {
|
|||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
||||
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) ────────────────────────────────────────────
|
||||
|
|
|
|||
28
App/tests/unit/storage-keys.test.ts
Normal file
28
App/tests/unit/storage-keys.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatCurrency, formatDate, formatDateTime,
|
||||
formatCurrency, formatCompactINR, formatDate, formatDateTime,
|
||||
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
||||
} from "@/lib/utils";
|
||||
|
||||
|
|
@ -32,6 +32,55 @@ 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", () => {
|
||||
it("returns a readable date string", () => {
|
||||
const result = formatDate(new Date("2026-04-29"));
|
||||
|
|
|
|||
64
App/tests/unit/vendors-table.test.tsx
Normal file
64
App/tests/unit/vendors-table.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
type Row = Parameters<typeof VendorsTable>[0]["vendors"][number];
|
||||
|
||||
const makeRow = (over: Partial<Row> = {}): Row => ({
|
||||
id: "v1",
|
||||
name: "Acme Marine Supplies",
|
||||
vendorId: "VND-001",
|
||||
gstin: null,
|
||||
address: null,
|
||||
isVerified: false,
|
||||
itemCount: 0,
|
||||
primaryContact: null,
|
||||
distanceKm: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("VendorsTable — vendor id (issue #57)", () => {
|
||||
it("renders the vendorId next to the name when present", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByText("Acme Marine Supplies")).toBeTruthy();
|
||||
expect(screen.getByText("VND-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the id (no placeholder) when vendorId is null", () => {
|
||||
render(<VendorsTable vendors={[makeRow({ vendorId: null })]} hasSite={false} />);
|
||||
expect(screen.queryByText("VND-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by vendorId", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "VND-999" } });
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still filters by name", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "beta" } });
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
});
|
||||
|
||||
it("advertises ID search in the placeholder", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByPlaceholderText(/Search by name, ID, GSTIN or address/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,36 @@ Claude in a steered session). The triage breakdown comment is plain (no bot
|
|||
marker) so, for `claude-queue` issues, the fix stage reads it back as refined
|
||||
requirements.
|
||||
|
||||
## Contribution policy (all changes via PR)
|
||||
|
||||
**Every change lands through a pull request — no direct pushes to `master`.** This applies
|
||||
to humans and to the automated pipeline alike (the watcher already opens PRs).
|
||||
|
||||
Each PR must include:
|
||||
|
||||
- **Tests** for any code change. Model: the integration test on `claude/issue-12` —
|
||||
it targets the prod-mirror test DB, anchors on existing rows, inserts fixtures via
|
||||
raw SQL (schema-tolerant), isolates them with a unique prefix, and cleans up in
|
||||
`afterEach`. Docs/config/automation-only PRs are exempt.
|
||||
- **Docs** updates where relevant (`App/README.md`, `App/CLAUDE.md`, `Docs/`,
|
||||
this file, `CHANGELOG.md`).
|
||||
|
||||
**Enforcement** — [`.forgejo/workflows/pr-checks.yml`](../.forgejo/workflows/pr-checks.yml)
|
||||
runs on every PR into `master`:
|
||||
|
||||
1. **Test-presence gate:** a PR touching `App/app|lib|components|hooks` with no test
|
||||
change fails. Justify genuine exceptions in the PR body for a reviewer to override.
|
||||
2. **Type-check:** `pnpm type-check` must be clean across the whole project (tests
|
||||
included). The test suite's old type baseline was repaired when this gate landed.
|
||||
3. **Unit tests:** `pnpm test` must pass.
|
||||
|
||||
All three are **hard** gates. `pnpm lint` is intentionally not run — it currently
|
||||
requires an interactive ESLint migration (a follow-up). Integration tests are
|
||||
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
|
||||
(not in this shared CI, to avoid prod-mirror schema drift).
|
||||
|
||||
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
|
||||
|
||||
## Components
|
||||
|
||||
| Piece | Where | Notes |
|
||||
|
|
@ -91,7 +121,11 @@ before a release tag deploys them to prod.
|
|||
- Checkout: `~/pelagia-staging` (separate from `~/pms` and `~/pelagia-autofix`)
|
||||
- Process: pm2 `ppms-staging` on **port 3200**, against the prod-mirror test DB
|
||||
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
|
||||
- Refresh to newer master + restart: re-run `~/issue-watcher/staging-up.sh`.
|
||||
- **Auto-refresh:** [`.forgejo/workflows/staging.yml`](../.forgejo/workflows/staging.yml)
|
||||
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`.
|
||||
- **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`:
|
||||
|
|
@ -106,30 +140,43 @@ before a release tag deploys them to prod.
|
|||
## Issue label lifecycle
|
||||
|
||||
```
|
||||
portal ──(triage)──▶ claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed
|
||||
└────▶ interactive (stops here — handle with Claude interactively)
|
||||
portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed
|
||||
└─────▶ triaged + interactive (stops here — handle with Claude interactively)
|
||||
```
|
||||
|
||||
- A `portal` issue with no decision label yet is triaged once (`maxTriagePerRun`
|
||||
per run). Triage adds `claude-queue` or `interactive` and posts a breakdown.
|
||||
- **Triage owns routing for every `portal` issue.** Each untriaged portal issue is
|
||||
triaged once (`maxTriagePerRun` per run); triage adds `triaged`, a routing label
|
||||
(`claude-queue` or `interactive`), a type label (`bug` or `feature`), and posts a
|
||||
breakdown. Triage skips an issue only once it carries `triaged`, `interactive`,
|
||||
`claude-working`, `claude-pr`, or `claude-failed`.
|
||||
- **`claude-queue` alone does NOT skip triage on a portal issue.** The Report Issue
|
||||
button may stamp `claude-queue` at creation; triage still claims the issue and
|
||||
decides routing (stripping the stray `claude-queue` if it routes to `interactive`).
|
||||
This is why triage works even if an older button build is deployed.
|
||||
- `claude-queue` → `claude-working` → `claude-pr` (PR opened) or `claude-failed`.
|
||||
- To retry a failed issue, re-add `claude-queue`.
|
||||
- To queue any manually-created issue for Claude (skipping triage), add
|
||||
`claude-queue` directly. To force human handling, add `interactive`.
|
||||
- Triage is skipped for issues that already carry any decision label.
|
||||
- To retry a failed issue, re-add `claude-queue` (and remove `claude-failed`).
|
||||
- To queue a **non-portal** issue for Claude (skipping triage), add `claude-queue`
|
||||
directly — triage never claims issues without the `portal` label.
|
||||
- To force a portal issue straight to fix, add `triaged` + `claude-queue` yourself.
|
||||
|
||||
## Releasing
|
||||
|
||||
After merging a Claude PR (or any change) on `master`:
|
||||
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on
|
||||
> `v*` tags — a bare tag like `0.2.2` will **NOT** deploy (the runner ignores it and prod
|
||||
> stays on the previous version). Push the **tag** specifically; pushing `master` alone
|
||||
> never deploys.
|
||||
|
||||
After merging PR(s) on `master`:
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
||||
git push pms1 master --tags
|
||||
git tag v0.2.2 # MUST start with "v"; semver: patch = fixes, minor = features
|
||||
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy
|
||||
```
|
||||
|
||||
The runner deploys the tag and restarts the app. Watch progress under
|
||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` +
|
||||
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch
|
||||
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
|
||||
## Operational notes
|
||||
|
||||
|
|
|
|||
|
|
@ -145,12 +145,16 @@ if [ ! -d "$WORKDIR/.git" ]; then
|
|||
git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com"
|
||||
fi
|
||||
|
||||
DECISION_LABELS="claude-queue interactive claude-working claude-pr claude-failed"
|
||||
# Triage OWNS routing for every portal issue. It claims a portal issue until it has
|
||||
# been triaged (`triaged`) or is already in progress/done. NOTE: claude-queue is
|
||||
# deliberately NOT a skip reason — the Report Issue button may stamp claude-queue at
|
||||
# creation, and triage must still decide claude-queue vs interactive itself.
|
||||
TRIAGE_SKIP_LABELS="interactive claude-working claude-pr claude-failed triaged"
|
||||
|
||||
# =====================================================================
|
||||
# Phase 1: triage new portal issues
|
||||
# =====================================================================
|
||||
dl_json=$(printf '%s\n' $DECISION_LABELS | jq -R . | jq -sc .)
|
||||
dl_json=$(printf '%s\n' $TRIAGE_SKIP_LABELS | jq -R . | jq -sc .)
|
||||
to_triage=$(issues_by_label portal | jq -c --argjson dl "$dl_json" \
|
||||
'[ .[] | select((.labels|map(.name)) as $have | ($dl | any(. as $d | $have|index($d))) | not) ] | sort_by(.number)')
|
||||
to_triage=$(printf '%s' "$to_triage" | jq -c ".[:$MAX_TRIAGE]")
|
||||
|
|
@ -167,7 +171,7 @@ while [ "$t" -lt "$n_triage" ]; do
|
|||
log "-- Triaging #$num: $title"
|
||||
reset_clone
|
||||
comments=$(comments_block "$num")
|
||||
rm -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" "$WORKDIR/CLAUDE_TRIAGE.md"
|
||||
rm -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt" "$WORKDIR/CLAUDE_TRIAGE.md"
|
||||
|
||||
prompt_file=$(mktemp)
|
||||
{
|
||||
|
|
@ -188,8 +192,11 @@ while [ "$t" -lt "$n_triage" ]; do
|
|||
printf '%s\n' " - interactive = needs human steering: ambiguous or underspecified, needs business content"
|
||||
printf '%s\n' " or a design decision, a schema migration, permissions/payments changes, an external"
|
||||
printf '%s\n' " dependency, or a large feature needing visual verification."
|
||||
printf '%s\n' "3. Write TWO files in the repository root, nothing else:"
|
||||
printf '%s\n' " - CLAUDE_TRIAGE_LABEL.txt -- a single line with EXACTLY one word: claude-queue OR interactive"
|
||||
printf '%s\n' "3. Classify the issue as a BUG (something is broken / not working as intended) or a"
|
||||
printf '%s\n' " FEATURE (new capability or a change/enhancement to existing behaviour)."
|
||||
printf '%s\n' "4. Write THREE files in the repository root, nothing else:"
|
||||
printf '%s\n' " - CLAUDE_TRIAGE_LABEL.txt -- one line, EXACTLY one word: claude-queue OR interactive"
|
||||
printf '%s\n' " - CLAUDE_TRIAGE_TYPE.txt -- one line, EXACTLY one word: bug OR feature"
|
||||
printf '%s\n' " - CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas"
|
||||
printf '%s\n' " involved, open questions, and a final one-line 'Routing rationale: ...'."
|
||||
} > "$prompt_file"
|
||||
|
|
@ -208,17 +215,34 @@ while [ "$t" -lt "$n_triage" ]; do
|
|||
fi
|
||||
breakdown=""
|
||||
[ -f "$WORKDIR/CLAUDE_TRIAGE.md" ] && breakdown=$(cat "$WORKDIR/CLAUDE_TRIAGE.md")
|
||||
type=""
|
||||
if [ -f "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt" ]; then
|
||||
traw=$(cat "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt")
|
||||
if printf '%s' "$traw" | grep -qiw feature; then type=feature
|
||||
elif printf '%s' "$traw" | grep -qiw bug; then type=bug; fi
|
||||
fi
|
||||
reset_clone
|
||||
|
||||
# Classify bug/feature regardless of routing outcome (additive, never clears).
|
||||
[ -n "$type" ] && { add_labels "$num" "$type"; log "Classified #$num as $type"; }
|
||||
|
||||
if [ -z "$label" ]; then
|
||||
log "Triage for #$num produced no valid decision; leaving for a human"
|
||||
# Mark triaged + strip any button-stamped claude-queue so it is NOT auto-fixed.
|
||||
set_labels "$num" "claude-queue" "triaged"
|
||||
add_comment "$num" "$BOT_MARKER
|
||||
[Claude triage] Could not auto-triage this issue. A human should review it and add either \`claude-queue\` or \`interactive\`."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Label FIRST so a comment failure cannot trigger a re-triage that double-posts.
|
||||
add_labels "$num" "$label"
|
||||
# Mark `triaged` so triage won't re-claim it. For interactive, strip any
|
||||
# claude-queue the Report Issue button may have stamped so the fix phase ignores it.
|
||||
if [ "$label" = "interactive" ]; then
|
||||
set_labels "$num" "claude-queue" "interactive triaged"
|
||||
else
|
||||
add_labels "$num" claude-queue triaged
|
||||
fi
|
||||
# No bot marker on the breakdown: it is genuine refined requirements and SHOULD
|
||||
# be fed to the fix stage (comments_block includes it).
|
||||
note=${breakdown:-"(no breakdown produced)"}
|
||||
|
|
@ -226,7 +250,7 @@ while [ "$t" -lt "$n_triage" ]; do
|
|||
|
||||
$note
|
||||
|
||||
**Routing:** \`$label\`"
|
||||
**Routing:** \`$label\`${type:+ | **Type:** \`$type\`}"
|
||||
log "Triaged #$num -> $label"
|
||||
done
|
||||
|
||||
|
|
@ -282,13 +306,20 @@ while [ "$f" -lt "$n_fix" ]; do
|
|||
printf '%s\n' " NEVER use a broad 'pkill -f next' -- it would kill the production app."
|
||||
printf '%s\n' "- Never connect to or modify the production database or the production app."
|
||||
printf '%s\n' ""
|
||||
printf '%s\n' "## Your job"
|
||||
printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)"
|
||||
printf '%s\n' "1. Investigate the issue and implement a focused, minimal fix in this repository."
|
||||
printf '%s\n' "2. Verify: run 'pnpm type-check' and 'pnpm lint' in App/. If behaviour is covered by unit"
|
||||
printf '%s\n' " tests, run them; for DB-backed behaviour, run integration tests against the test DB above."
|
||||
printf '%s\n' "3. Add or adjust tests when it makes sense."
|
||||
printf '%s\n' "4. Commit ALL changes to the current branch with a conventional message ending: Fixes #$num"
|
||||
printf '%s\n' "5. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
|
||||
printf '%s\n' "2. REQUIRED: add or update a test that fails before your fix and passes after. Model it on"
|
||||
printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts (from issue #12): target the"
|
||||
printf '%s\n' " prod-mirror test DB, anchor on existing rows (findFirstOrThrow), insert fixtures via raw"
|
||||
printf '%s\n' " SQL with a unique prefix, and clean them up in afterEach. The PR check REJECTS code"
|
||||
printf '%s\n' " changes under App/app|lib|components|hooks with no test change."
|
||||
printf '%s\n' "3. Verify: 'pnpm type-check' (no new app-code errors) and run your test against the test DB:"
|
||||
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
|
||||
printf '%s\n' "4. REQUIRED: update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/,"
|
||||
printf '%s\n' " CHANGELOG.md) — skip only if nothing documented is affected."
|
||||
printf '%s\n' "5. Commit ALL changes (fix + test + docs) to the current branch with a conventional message"
|
||||
printf '%s\n' " ending: Fixes #$num"
|
||||
printf '%s\n' "6. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
|
||||
printf '%s\n' "If the issue is unclear, too risky (migrations, payments, permissions), or you cannot verify"
|
||||
printf '%s\n' "the fix, make NO commits and write a short explanation to CLAUDE_RESULT.md in the repo root."
|
||||
} > "$prompt_file"
|
||||
|
|
|
|||
|
|
@ -67,8 +67,12 @@ echo "Generating Prisma client..."; pnpm db:generate
|
|||
# must be applied or the new code 500s on the missing columns.
|
||||
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
|
||||
pm2 restart "$NAME" --update-env
|
||||
pm2 restart "$NAME"
|
||||
else
|
||||
pm2 start "$DIR/App/run-staging.sh" --name "$NAME" --interpreter bash
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue