Compare commits
11 commits
feat/po-ca
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c4c370f6 | |||
| 65a9335de1 | |||
| cb661949d9 | |||
| 610c9aa56d | |||
| 6677ef4fcf | |||
| 4fee393c84 | |||
| 3b9bc0be1b | |||
| 0fdd899096 | |||
| 43d139234e | |||
|
|
cb25d2e5fd | ||
| 9de60200f9 |
9 changed files with 349 additions and 34 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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) };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
|
||||
vi.mock("@/app/(portal)/po/[id]/actions", () => ({ cancelPo: vi.fn(), supersedePo: vi.fn() }));
|
||||
|
||||
import { CancelPoButton } from "@/components/po/cancel-po-controls";
|
||||
|
||||
// Regression guard: the theme only defines danger / -50 / -100 / -700, so an
|
||||
// undefined shade like bg-danger-600 renders no background → the button was
|
||||
// invisible (white text on nothing). Both cancel buttons must use `bg-danger`.
|
||||
|
||||
describe("CancelPoButton", () => {
|
||||
it("renders the trigger as a filled red (bg-danger) button with white text", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
const btn = screen.getByRole("button", { name: "Cancel PO" });
|
||||
// standalone `bg-danger` (a defined token), NOT `bg-danger-600` (undefined → invisible)
|
||||
expect(btn.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(btn.className).toContain("text-white");
|
||||
});
|
||||
|
||||
it("opens a modal whose confirm button is a visible filled danger button", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" });
|
||||
expect(confirm.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(confirm.className).toContain("text-white");
|
||||
|
||||
// Keep PO is always present as the safe default.
|
||||
expect(screen.getByRole("button", { name: "Keep PO" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the confirm action disabled until 'cancel' is typed and a reason given", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" }) as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Duplicate order/i), { target: { value: "No longer needed" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("cancel"), { target: { value: "cancel" } });
|
||||
expect(confirm.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
55
App/tests/unit/image-size.test.ts
Normal file
55
App/tests/unit/image-size.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||
|
||||
function fakePng(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(24);
|
||||
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47; // PNG signature start
|
||||
b.writeUInt32BE(width, 16);
|
||||
b.writeUInt32BE(height, 20);
|
||||
return b;
|
||||
}
|
||||
|
||||
function fakeJpeg(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(20);
|
||||
b[0] = 0xff; b[1] = 0xd8; // SOI
|
||||
b[2] = 0xff; b[3] = 0xc0; // SOF0 marker
|
||||
b.writeUInt16BE(0x11, 4); // segment length
|
||||
b[6] = 8; // precision
|
||||
b.writeUInt16BE(height, 7);
|
||||
b.writeUInt16BE(width, 9);
|
||||
return b;
|
||||
}
|
||||
|
||||
describe("getImageSize", () => {
|
||||
it("reads PNG dimensions", () => {
|
||||
expect(getImageSize(fakePng(640, 480))).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("reads JPEG dimensions from the SOF marker", () => {
|
||||
expect(getImageSize(fakeJpeg(1024, 768))).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it("returns null for non-image data", () => {
|
||||
expect(getImageSize(Buffer.from("not an image at all"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleToBox", () => {
|
||||
it("preserves a square aspect ratio (downscale by the binding side)", () => {
|
||||
const r = scaleToBox({ width: 200, height: 200 }, 96, 52);
|
||||
expect(r.width).toBe(r.height); // stays square — never stretched
|
||||
expect(r.height).toBeLessThanOrEqual(52);
|
||||
});
|
||||
|
||||
it("fits a wide image to the width and keeps the ratio", () => {
|
||||
const r = scaleToBox({ width: 360, height: 96 }, 165, 44);
|
||||
expect(r.width).toBeLessThanOrEqual(165);
|
||||
expect(r.height).toBeLessThanOrEqual(44);
|
||||
expect(r.width / r.height).toBeCloseTo(360 / 96, 1);
|
||||
});
|
||||
|
||||
it("keeps the watermark's landscape ratio", () => {
|
||||
const r = scaleToBox({ width: 1400, height: 1000 }, 880, 720);
|
||||
expect(r).toEqual({ width: 880, height: 629 });
|
||||
});
|
||||
});
|
||||
39
App/tests/unit/po-export-layout.test.ts
Normal file
39
App/tests/unit/po-export-layout.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||
|
||||
const BLOCK = 503; // px width of the A-D signatory block
|
||||
|
||||
describe("signatoryLayout", () => {
|
||||
it("centres the signature in the block", () => {
|
||||
const { sigLeft } = signatoryLayout({ blockPx: BLOCK, sig: { width: 153, height: 44 }, stamp: null });
|
||||
expect(sigLeft).not.toBeNull();
|
||||
expect(sigLeft! + 153 / 2).toBeCloseTo(BLOCK / 2, 0); // centre ≈ block centre
|
||||
});
|
||||
|
||||
it("places the stamp to the RIGHT of the signature with no overlap", () => {
|
||||
const sig = { width: 153, height: 44 };
|
||||
const stamp = { width: 67, height: 66 };
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp, gap: 10 });
|
||||
expect(stampLeft! ).toBeGreaterThanOrEqual(sigLeft! + sig.width); // starts at/after signature ends
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); // stays inside the block
|
||||
});
|
||||
|
||||
it("never overlaps even with the widest signature + stamp", () => {
|
||||
const sig = { width: 165, height: 44 }; // scaleToBox caps
|
||||
const stamp = { width: 80, height: 66 }; // scaleToBox caps
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp });
|
||||
expect(stampLeft!).toBeGreaterThanOrEqual(sigLeft! + sig.width);
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK);
|
||||
});
|
||||
|
||||
it("right-aligns the stamp when there is no signature", () => {
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig: null, stamp: { width: 67, height: 66 } });
|
||||
expect(sigLeft).toBeNull();
|
||||
expect(stampLeft! + 67).toBeLessThanOrEqual(BLOCK);
|
||||
expect(stampLeft!).toBeGreaterThan(BLOCK / 2); // on the right side
|
||||
});
|
||||
|
||||
it("returns nulls when there are no images", () => {
|
||||
expect(signatoryLayout({ blockPx: BLOCK, sig: null, stamp: null })).toEqual({ sigLeft: null, stampLeft: null });
|
||||
});
|
||||
});
|
||||
64
App/tests/unit/vendors-table.test.tsx
Normal file
64
App/tests/unit/vendors-table.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
type Row = Parameters<typeof VendorsTable>[0]["vendors"][number];
|
||||
|
||||
const makeRow = (over: Partial<Row> = {}): Row => ({
|
||||
id: "v1",
|
||||
name: "Acme Marine Supplies",
|
||||
vendorId: "VND-001",
|
||||
gstin: null,
|
||||
address: null,
|
||||
isVerified: false,
|
||||
itemCount: 0,
|
||||
primaryContact: null,
|
||||
distanceKm: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("VendorsTable — vendor id (issue #57)", () => {
|
||||
it("renders the vendorId next to the name when present", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByText("Acme Marine Supplies")).toBeTruthy();
|
||||
expect(screen.getByText("VND-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the id (no placeholder) when vendorId is null", () => {
|
||||
render(<VendorsTable vendors={[makeRow({ vendorId: null })]} hasSite={false} />);
|
||||
expect(screen.queryByText("VND-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by vendorId", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "VND-999" } });
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still filters by name", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "beta" } });
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
});
|
||||
|
||||
it("advertises ID search in the placeholder", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByPlaceholderText(/Search by name, ID, GSTIN or address/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue