Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
e4c4c370f6 Merge pull request 'fix(po): keep export stamp clear of the signature (no overlap)' (#61) from fix/stamp-no-overlap into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m4s
Reviewed-on: #61
2026-06-21 10:07:24 +00:00
65a9335de1 fix(po): keep the export stamp clear of the signature (no overlap)
All checks were successful
PR checks / checks (pull_request) Successful in 35s
PR checks / integration (pull_request) Successful in 26s
Uploaded signatures/stamps aren't always transparent PNGs, so an opaque stamp
overlapping the signature/name would cover them. Extract the signatory-block
geometry into a tested helper (signatoryLayout): the signature is centred over
the name and the stamp sits to its RIGHT with a 10px gap — never overlapping.

- lib/po-export-layout.ts (signatoryLayout) + unit test
- export route uses it instead of inline overlap math

Verified in a real export: signature 175-328px (centred), stamp 338-405px
(10px gap, no overlap), stamp drawn behind the signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:35:09 +05:30
cb661949d9 Merge pull request 'fix(po): size XLSX export images by pixels (aspect preserved)' (#60) from fix/xlsx-export-asset-sizing into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m3s
Reviewed-on: #60
2026-06-21 08:21:30 +00:00
610c9aa56d fix(po): centre signature over name; stamp to its right and behind it
All checks were successful
PR checks / checks (pull_request) Successful in 34s
PR checks / integration (pull_request) Successful in 25s
In the XLSX signatory block, place the approver signature centred over the
name and tuck the stamp to its right with a slight overlap. The stamp is now
drawn before the signature so it layers behind it (Excel z-order = add order).

Images are positioned by absolute pixels via native EMU offsets — ExcelJS's
fractional-column anchors don't map cleanly to pixels (the stamp was landing
on top of the signature centre instead of to its right). Verified in a real
export: signature centre 252px in the 503px A-D block (centred), stamp to the
right (305-372px), stamp drawn behind the signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:45:56 +05:30
6677ef4fcf fix(po): size XLSX export images by pixels (aspect preserved)
All checks were successful
PR checks / checks (pull_request) Successful in 34s
PR checks / integration (pull_request) Successful in 26s
The logo, signature, stamp and cancelled watermark were placed with ExcelJS
two-cell (tl/br) anchors, which stretch each image to fill a cell range —
distorting them and making the watermark text small/squished. The PDF looked
fine because CSS sizes by aspect.

- New lib/image-size.ts: getImageSize (PNG/JPEG/WebP header parse) + scaleToBox.
- Export route now places each image with a oneCell `tl` + pixel `ext`,
  aspect preserved and matched to the PDF sizes (logo ≤96×52, signature ≤165×44,
  stamp ≤80×66, watermark ≤880×720).
- Watermark regenerated as a landscape canvas with the text filling it, so it
  spans the page like the PDF instead of sitting small in the centre.
- Unit test for getImageSize + scaleToBox.

Verified structurally: generated XLSX uses oneCellAnchors with fixed pixel
ext sizes (49×52 / 45×44 / 67×66 / 880×629), not stretched cell ranges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:27:15 +05:30
4fee393c84 Merge pull request 'fix: Add Vendor-ID to /inventory/vendors search' (#58) from claude/issue-57 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m5s
Reviewed-on: #58
2026-06-21 07:32:06 +00:00
3b9bc0be1b Merge branch 'master' into claude/issue-57
All checks were successful
PR checks / checks (pull_request) Successful in 32s
PR checks / integration (pull_request) Successful in 27s
2026-06-21 07:31:33 +00:00
0fdd899096 Merge pull request 'fix(po): make cancel buttons red & visible' (#59) from fix/cancel-button-red into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #59
2026-06-21 07:30:49 +00:00
43d139234e fix(po): make cancel buttons red & visible (use defined danger tokens)
All checks were successful
PR checks / checks (pull_request) Successful in 31s
PR checks / integration (pull_request) Successful in 25s
The theme only defines danger / danger-50 / danger-100 / danger-700, so
bg-danger-600 / danger-500 / danger-200 generated no CSS — the modal confirm
button was white-on-nothing (invisible) and the header button wasn't red.

- Header "Cancel PO" button → solid red (bg-danger text-white hover:bg-danger-700)
- Modal "Cancel this PO" confirm button → bg-danger (now visible)
- Inputs/asterisk/banner border → defined danger tokens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:59:20 +05:30
Claude (auto-fix)
cb25d2e5fd feat(vendors): search by vendor ID and show it next to the name
All checks were successful
PR checks / checks (pull_request) Successful in 34s
PR checks / integration (pull_request) Successful in 26s
On /inventory/vendors, include vendorId in the search filter and render
it as a muted mono badge beside the vendor name. The vendorId data was
already passed to the client component, so this is a presentation/filter
change only. Unverified vendors (no vendorId) render unchanged.

Fixes #57

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 12:43:21 +05:30
9de60200f9 Merge pull request 'feat(po): cancel POs + optional supersede link (#53)' (#56) from feat/po-cancel-supersede into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m7s
Reviewed-on: #56
2026-06-21 06:58:31 +00:00
11 changed files with 355 additions and 40 deletions

View file

@ -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>
)}

View file

@ -4,7 +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 } from "@/lib/cancelled-watermark";
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) ──────
@ -32,12 +34,15 @@ function mimeForKey(key: string): string {
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
}
// Download a stored image and return it base64-encoded (or null if missing).
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
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;
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
const size = getImageSize(buf) ?? { width: 100, height: 100 };
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
}
// ── Route ─────────────────────────────────────────────────────────────────────
@ -129,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 },
@ -140,6 +146,7 @@ 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 };
}
}
}
@ -278,15 +285,15 @@ 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, columns A-B) ══════════
// ══ 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.1, row: 0.1 } as unknown as ExcelJS.Anchor,
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
ext: scaleToBox(logoImg, 96, 52),
editAs: "oneCell",
});
}
@ -453,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() } });
@ -481,19 +519,6 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.getRow(SIG_ROW + 2).height = 14;
ws.getRow(SIG_ROW + 3).height = 14;
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
if (stampImg) {
const stampId = wb.addImage({
base64: stampImg.base64,
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(stampId, {
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}
// Right sig block (vendor)
const vName = po.vendor?.name ?? "";
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
@ -511,12 +536,15 @@ export async function GET(request: NextRequest, { params }: Props) {
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" floating over the sheet ═══
// ══ 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.2, row: 4 } as unknown as ExcelJS.Anchor,
br: { col: 9, row: BAR_ROW } as unknown as ExcelJS.Anchor,
tl: { col: 0.15, row: 5 } as unknown as ExcelJS.Anchor,
ext,
editAs: "oneCell",
});
}

View file

@ -47,7 +47,7 @@ export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: str
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-lg border border-danger-200 bg-white px-3 py-2 text-sm font-medium text-danger-600 hover:bg-danger-50 transition-colors"
className="rounded-lg bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 transition-colors"
>
Cancel PO
</button>
@ -65,7 +65,7 @@ export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: str
</p>
<label className="mt-4 block text-xs font-medium text-neutral-700">
Reason for cancellation <span className="text-danger-600">*</span>
Reason for cancellation <span className="text-danger">*</span>
</label>
<textarea
value={reason}
@ -73,7 +73,7 @@ export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: str
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-500 focus:outline-none focus:ring-2 focus:ring-danger-500/20"
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">
@ -83,7 +83,7 @@ export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: str
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-500 focus:outline-none focus:ring-2 focus:ring-danger-500/20"
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>}
@ -101,7 +101,7 @@ export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: str
type="button"
onClick={handleCancel}
disabled={!canSubmit}
className="rounded-lg bg-danger-600 px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
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>

View file

@ -238,7 +238,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
{po.status === "CANCELLED" && (
<div className="rounded-lg border border-danger-200 bg-danger-50 px-4 py-3">
<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>

File diff suppressed because one or more lines are too long

46
App/lib/image-size.ts Normal file
View 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) };
}

View 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 };
}

View 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);
});
});

View 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 });
});
});

View 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 });
});
});

View 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();
});
});