Compare commits

..

2 commits

Author SHA1 Message Date
5f6706ff7d Merge commit 'a8d772d' into feat/po-cancel-supersede 2026-06-21 12:38:02 +05:30
78478c4e17 fix(po): make cancel buttons red & visible (use defined danger tokens)
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:37:25 +05:30
9 changed files with 34 additions and 349 deletions

View file

@ -41,7 +41,6 @@ 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))
)
@ -90,7 +89,7 @@ export function VendorsTable({
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, ID, GSTIN or address…"
placeholder="Search by name, GSTIN or address…"
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
{query && (
@ -152,9 +151,6 @@ export function VendorsTable({
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
{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,9 +4,7 @@ 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";
import { CANCELLED_WATERMARK_PNG_BASE64 } from "@/lib/cancelled-watermark";
// ── Company fallback constants (used when no company is linked to a PO) ──────
@ -34,15 +32,12 @@ function mimeForKey(key: string): string {
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> {
// 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> {
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 };
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
}
// ── Route ─────────────────────────────────────────────────────────────────────
@ -134,7 +129,6 @@ 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 },
@ -146,7 +140,6 @@ 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 };
}
}
}
@ -285,15 +278,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; aspect preserved) ═════
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
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),
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}
@ -460,47 +453,16 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.getRow(SIG_ROW + 1).height = 14;
ws.getRow(SIG_ROW + 2).height = 14;
// 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) {
// Left sig block (approver — the manager who authorized the PO)
if (signatureBase64) {
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, {
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
ext: sigExt,
// 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,
editAs: "oneCell",
});
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
@ -519,6 +481,19 @@ 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 });
@ -536,15 +511,12 @@ 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" 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.
// ══ Cancelled watermark — diagonal "CANCELLED" floating over the sheet ═══
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,
tl: { col: 0.2, row: 4 } as unknown as ExcelJS.Anchor,
br: { col: 9, row: BAR_ROW } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}

File diff suppressed because one or more lines are too long

View file

@ -1,46 +0,0 @@
// Image dimension helpers used to size XLSX floating images by pixels with the
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
// an image to fill a cell range, which distorts logos / signatures / stamps.
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
// PNG — IHDR width/height at byte offsets 16 / 20
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
// JPEG — scan segments for a Start-Of-Frame marker
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
let o = 2;
while (o + 9 < buf.length) {
if (buf[o] !== 0xff) { o++; continue; }
const m = buf[o + 1];
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
}
o += 2 + buf.readUInt16BE(o + 2);
}
}
// WebP — RIFF container, VP8 / VP8L / VP8X
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
const fmt = buf.toString("ascii", 12, 16);
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
if (fmt === "VP8X") {
return {
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
};
}
}
return null;
}
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
export function scaleToBox(
natural: { width: number; height: number },
maxW: number,
maxH: number
): { width: number; height: number } {
const s = Math.min(maxW / natural.width, maxH / natural.height);
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
}

View file

@ -1,32 +0,0 @@
// 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

@ -1,45 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
vi.mock("@/app/(portal)/po/[id]/actions", () => ({ cancelPo: vi.fn(), supersedePo: vi.fn() }));
import { CancelPoButton } from "@/components/po/cancel-po-controls";
// Regression guard: the theme only defines danger / -50 / -100 / -700, so an
// undefined shade like bg-danger-600 renders no background → the button was
// invisible (white text on nothing). Both cancel buttons must use `bg-danger`.
describe("CancelPoButton", () => {
it("renders the trigger as a filled red (bg-danger) button with white text", () => {
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
const btn = screen.getByRole("button", { name: "Cancel PO" });
// standalone `bg-danger` (a defined token), NOT `bg-danger-600` (undefined → invisible)
expect(btn.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
expect(btn.className).toContain("text-white");
});
it("opens a modal whose confirm button is a visible filled danger button", () => {
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
const confirm = screen.getByRole("button", { name: "Cancel this PO" });
expect(confirm.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
expect(confirm.className).toContain("text-white");
// Keep PO is always present as the safe default.
expect(screen.getByRole("button", { name: "Keep PO" })).toBeInTheDocument();
});
it("keeps the confirm action disabled until 'cancel' is typed and a reason given", () => {
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
const confirm = screen.getByRole("button", { name: "Cancel this PO" }) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
fireEvent.change(screen.getByPlaceholderText(/Duplicate order/i), { target: { value: "No longer needed" } });
fireEvent.change(screen.getByPlaceholderText("cancel"), { target: { value: "cancel" } });
expect(confirm.disabled).toBe(false);
});
});

View file

@ -1,55 +0,0 @@
import { describe, it, expect } from "vitest";
import { getImageSize, scaleToBox } from "@/lib/image-size";
function fakePng(width: number, height: number): Buffer {
const b = Buffer.alloc(24);
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47; // PNG signature start
b.writeUInt32BE(width, 16);
b.writeUInt32BE(height, 20);
return b;
}
function fakeJpeg(width: number, height: number): Buffer {
const b = Buffer.alloc(20);
b[0] = 0xff; b[1] = 0xd8; // SOI
b[2] = 0xff; b[3] = 0xc0; // SOF0 marker
b.writeUInt16BE(0x11, 4); // segment length
b[6] = 8; // precision
b.writeUInt16BE(height, 7);
b.writeUInt16BE(width, 9);
return b;
}
describe("getImageSize", () => {
it("reads PNG dimensions", () => {
expect(getImageSize(fakePng(640, 480))).toEqual({ width: 640, height: 480 });
});
it("reads JPEG dimensions from the SOF marker", () => {
expect(getImageSize(fakeJpeg(1024, 768))).toEqual({ width: 1024, height: 768 });
});
it("returns null for non-image data", () => {
expect(getImageSize(Buffer.from("not an image at all"))).toBeNull();
});
});
describe("scaleToBox", () => {
it("preserves a square aspect ratio (downscale by the binding side)", () => {
const r = scaleToBox({ width: 200, height: 200 }, 96, 52);
expect(r.width).toBe(r.height); // stays square — never stretched
expect(r.height).toBeLessThanOrEqual(52);
});
it("fits a wide image to the width and keeps the ratio", () => {
const r = scaleToBox({ width: 360, height: 96 }, 165, 44);
expect(r.width).toBeLessThanOrEqual(165);
expect(r.height).toBeLessThanOrEqual(44);
expect(r.width / r.height).toBeCloseTo(360 / 96, 1);
});
it("keeps the watermark's landscape ratio", () => {
const r = scaleToBox({ width: 1400, height: 1000 }, 880, 720);
expect(r).toEqual({ width: 880, height: 629 });
});
});

View file

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

@ -1,64 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
type Row = Parameters<typeof VendorsTable>[0]["vendors"][number];
const makeRow = (over: Partial<Row> = {}): Row => ({
id: "v1",
name: "Acme Marine Supplies",
vendorId: "VND-001",
gstin: null,
address: null,
isVerified: false,
itemCount: 0,
primaryContact: null,
distanceKm: null,
...over,
});
describe("VendorsTable — vendor id (issue #57)", () => {
it("renders the vendorId next to the name when present", () => {
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
expect(screen.getByText("Acme Marine Supplies")).toBeTruthy();
expect(screen.getByText("VND-001")).toBeTruthy();
});
it("omits the id (no placeholder) when vendorId is null", () => {
render(<VendorsTable vendors={[makeRow({ vendorId: null })]} hasSite={false} />);
expect(screen.queryByText("VND-001")).toBeNull();
});
it("filters by vendorId", () => {
const rows = [
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
];
render(<VendorsTable vendors={rows} hasSite={false} />);
const search = screen.getByPlaceholderText(/Search by name/i);
fireEvent.change(search, { target: { value: "VND-999" } });
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
expect(screen.getByText("Beta Traders")).toBeTruthy();
});
it("still filters by name", () => {
const rows = [
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
];
render(<VendorsTable vendors={rows} hasSite={false} />);
const search = screen.getByPlaceholderText(/Search by name/i);
fireEvent.change(search, { target: { value: "beta" } });
expect(screen.getByText("Beta Traders")).toBeTruthy();
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
});
it("advertises ID search in the placeholder", () => {
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
expect(screen.getByPlaceholderText(/Search by name, ID, GSTIN or address/i)).toBeTruthy();
});
});