feat(po): per-company logo, stamp & brand bar on exported POs
Companies can upload a logo and a stamp/seal (Admin → Companies → Edit → Branding); both render on exported PDF and XLSX purchase orders. A fixed brand-colour bar (#92D050, matching the sample PO) runs along the bottom of every export. - Company.logoKey / stampKey + migration - buildCompanyAssetKey() deterministic storage keys (overwrite-in-place) - uploadCompanyAsset / removeCompanyAsset server actions (≤4MB PNG/JPG/WebP, manage_vessels_accounts gated) - CompanyBrandingUploader in the company edit dialog with live previews - Export route embeds logo (top-left), stamp (signatory block) and brand bar in both ExcelJS and print-HTML paths - Unit test (storage keys) + integration test (branding actions) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1feb43186d
commit
1071cb226f
11 changed files with 480 additions and 20 deletions
|
|
@ -3,11 +3,21 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
type ActionResult = { ok: true } | { error: string };
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
// Branding assets (logo + stamp) shown on exported POs.
|
||||||
|
const ASSET_MIME: Record<string, string> = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/jpg": "jpg",
|
||||||
|
"image/webp": "webp",
|
||||||
|
};
|
||||||
|
const ASSET_MAX_BYTES = 4 * 1024 * 1024; // 4 MB — banners/seals can be larger than signatures
|
||||||
|
|
||||||
const companySchema = z.object({
|
const companySchema = z.object({
|
||||||
name: z.string().min(1, "Company name is required"),
|
name: z.string().min(1, "Company name is required"),
|
||||||
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
||||||
|
|
@ -98,6 +108,58 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Branding assets (logo + stamp) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function uploadCompanyAsset(formData: FormData): Promise<ActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = formData.get("companyId") as string | null;
|
||||||
|
const type = formData.get("type") as string | null;
|
||||||
|
if (!companyId) return { error: "Company ID is required" };
|
||||||
|
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||||
|
|
||||||
|
const company = await db.company.findUnique({ where: { id: companyId }, select: { id: true } });
|
||||||
|
if (!company) return { error: "Company not found" };
|
||||||
|
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
if (!file || file.size === 0) return { error: "No file provided" };
|
||||||
|
if (file.size > ASSET_MAX_BYTES) return { error: "Image must be under 4 MB" };
|
||||||
|
|
||||||
|
const ext = ASSET_MIME[file.type];
|
||||||
|
if (!ext) return { error: "Image must be a PNG, JPG, or WebP" };
|
||||||
|
|
||||||
|
const key = buildCompanyAssetKey(companyId, type, ext);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await uploadBuffer(key, buffer, file.type);
|
||||||
|
|
||||||
|
await db.company.update({
|
||||||
|
where: { id: companyId },
|
||||||
|
data: type === "logo" ? { logoKey: key } : { stampKey: key },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/companies");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCompanyAsset(companyId: string, type: "logo" | "stamp"): Promise<ActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||||
|
|
||||||
|
await db.company.update({
|
||||||
|
where: { id: companyId },
|
||||||
|
data: type === "logo" ? { logoKey: null } : { stampKey: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/companies");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export type CompanyRow = {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
invoiceEmail: string | null;
|
invoiceEmail: string | null;
|
||||||
invoiceAddress: string | null;
|
invoiceAddress: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
stampUrl: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createCompany, updateCompany } from "./actions";
|
import { createCompany, updateCompany } from "./actions";
|
||||||
|
import { CompanyBrandingUploader } from "./company-branding-uploader";
|
||||||
|
|
||||||
type CompanyRow = {
|
type CompanyRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -16,6 +17,8 @@ type CompanyRow = {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
invoiceEmail: string | null;
|
invoiceEmail: string | null;
|
||||||
invoiceAddress: string | null;
|
invoiceAddress: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
stampUrl: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,6 +70,27 @@ function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
||||||
<label className={LABEL}>Invoice Address <span className="font-normal text-neutral-400">(shown on exported POs)</span></label>
|
<label className={LABEL}>Invoice Address <span className="font-normal text-neutral-400">(shown on exported POs)</span></label>
|
||||||
<textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
|
<textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Branding (shown on exported POs) ── */}
|
||||||
|
<div className="border-t border-neutral-200 pt-3 mt-1">
|
||||||
|
<p className="text-xs font-semibold text-neutral-700 mb-2">Branding <span className="font-normal text-neutral-400">(shown on exported POs)</span></p>
|
||||||
|
{company?.id ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-400">Save the company first, then upload a logo and stamp from Edit.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { CompaniesTable } from "./companies-table";
|
import { CompaniesTable } from "./companies-table";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
@ -16,21 +17,23 @@ export default async function AdminCompaniesPage() {
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const rows = await Promise.all(
|
||||||
<CompaniesTable
|
companies.map(async (c) => ({
|
||||||
companies={companies.map((c) => ({
|
id: c.id,
|
||||||
id: c.id,
|
name: c.name,
|
||||||
name: c.name,
|
code: c.code,
|
||||||
code: c.code,
|
gstNumber: c.gstNumber,
|
||||||
gstNumber: c.gstNumber,
|
address: c.address,
|
||||||
address: c.address,
|
telephone: c.telephone,
|
||||||
telephone: c.telephone,
|
mobile: c.mobile,
|
||||||
mobile: c.mobile,
|
email: c.email,
|
||||||
email: c.email,
|
invoiceEmail: c.invoiceEmail,
|
||||||
invoiceEmail: c.invoiceEmail,
|
invoiceAddress: c.invoiceAddress,
|
||||||
invoiceAddress: c.invoiceAddress,
|
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
|
||||||
isActive: c.isActive,
|
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
|
||||||
}))}
|
isActive: c.isActive,
|
||||||
/>
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return <CompaniesTable companies={rows} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,22 @@ function fmtNum(n: number, dec = 2): string {
|
||||||
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
|
||||||
|
const BRAND_BAR_COLOR = "#92D050";
|
||||||
|
|
||||||
|
function mimeForKey(key: string): string {
|
||||||
|
const ext = key.split(".").pop()?.toLowerCase();
|
||||||
|
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props { params: Promise<{ id: string }> }
|
interface Props { params: Promise<{ id: string }> }
|
||||||
|
|
@ -125,6 +141,10 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Company branding (logo top-left, stamp/seal in the signatory block)
|
||||||
|
const logoImg = await fetchImage(co?.logoKey);
|
||||||
|
const stampImg = await fetchImage(co?.stampKey);
|
||||||
|
|
||||||
const ext = po as {
|
const ext = po as {
|
||||||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||||||
requisitionNo?: string | null; requisitionDate?: Date | null;
|
requisitionNo?: string | null; requisitionDate?: Date | null;
|
||||||
|
|
@ -255,6 +275,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.mergeCells("A4:I4");
|
ws.mergeCells("A4:I4");
|
||||||
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
||||||
|
|
||||||
|
// ══ Company logo (floats top-left over the header, 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.1, row: 0.1 } as unknown as ExcelJS.Anchor,
|
||||||
|
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
|
||||||
|
editAs: "oneCell",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
||||||
ws.getRow(5).height = 18;
|
ws.getRow(5).height = 18;
|
||||||
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
||||||
|
|
@ -445,6 +478,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.getRow(SIG_ROW + 2).height = 14;
|
ws.getRow(SIG_ROW + 2).height = 14;
|
||||||
ws.getRow(SIG_ROW + 3).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)
|
// Right sig block (vendor)
|
||||||
const vName = po.vendor?.name ?? "";
|
const vName = po.vendor?.name ?? "";
|
||||||
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
||||||
|
|
@ -454,6 +500,14 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
||||||
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
||||||
|
|
||||||
|
// ══ Brand bar (full-width colour strip at the very bottom) ═══════════════
|
||||||
|
const BAR_ROW = SIG_ROW + 4;
|
||||||
|
const barArgb = "FF" + BRAND_BAR_COLOR.replace("#", "").toUpperCase();
|
||||||
|
const barFill = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: barArgb } };
|
||||||
|
ws.getRow(BAR_ROW).height = 16;
|
||||||
|
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
||||||
|
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
||||||
|
|
||||||
// ── Serialise ─────────────────────────────────────────────────────────
|
// ── Serialise ─────────────────────────────────────────────────────────
|
||||||
const buf = await wb.xlsx.writeBuffer();
|
const buf = await wb.xlsx.writeBuffer();
|
||||||
const slug = po.poNumber.replace(/\//g, "-");
|
const slug = po.poNumber.replace(/\//g, "-");
|
||||||
|
|
@ -506,9 +560,20 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
color: #111;
|
color: #111;
|
||||||
margin: 10mm 12mm;
|
margin: 10mm 12mm;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
|
.header-band { position: relative; }
|
||||||
|
.co-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
max-height: 52px;
|
||||||
|
max-width: 92px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.co-name {
|
.co-name {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13pt;
|
font-size: 13pt;
|
||||||
|
|
@ -568,6 +633,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
/* ── Signatures ── */
|
/* ── Signatures ── */
|
||||||
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||||
.sig-box {
|
.sig-box {
|
||||||
|
position: relative;
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
width: 44%;
|
width: 44%;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
|
@ -579,9 +645,26 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
}
|
}
|
||||||
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
||||||
.sig-sub { font-size: 7.5pt; }
|
.sig-sub { font-size: 7.5pt; }
|
||||||
|
.sig-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 4px;
|
||||||
|
max-height: 66px;
|
||||||
|
max-width: 88px;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.spacer { margin: 4px 0; }
|
.spacer { margin: 4px 0; }
|
||||||
|
|
||||||
|
/* ── Brand bar (bottom) ── */
|
||||||
|
.brand-bar {
|
||||||
|
height: 14px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
background: ${BRAND_BAR_COLOR};
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
body { margin: 8mm 10mm; }
|
body { margin: 8mm 10mm; }
|
||||||
|
|
@ -598,9 +681,12 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||||
<div class="co-name">${CO_NAME}</div>
|
<div class="header-band">
|
||||||
<div class="co-addr">${CO_ADDR}</div>
|
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
||||||
<div class="co-tel">${CO_TEL}</div>
|
<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>
|
<div class="po-title">PURCHASE ORDER</div>
|
||||||
|
|
||||||
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||||
|
|
@ -718,6 +804,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||||
<div class="sig">
|
<div class="sig">
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
|
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
||||||
${signatureBase64
|
${signatureBase64
|
||||||
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
||||||
: `<div class="sig-name">${approvedBy}</div>`
|
: `<div class="sig-name">${approvedBy}</div>`
|
||||||
|
|
@ -725,7 +812,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
<div>
|
<div>
|
||||||
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
||||||
<div class="sig-sub">Authorized Signatory & Stamp</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>
|
</div>
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
|
|
@ -737,6 +824,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||||
|
<div class="brand-bar"></div>
|
||||||
|
|
||||||
<script>window.onload = function() { window.print(); };</script>
|
<script>window.onload = function() { window.print(); };</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,18 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
||||||
return `signatures/${userId}.${ext}`;
|
return `signatures/${userId}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for a company branding asset (logo or stamp/seal).
|
||||||
|
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||||
|
*/
|
||||||
|
export function buildCompanyAssetKey(
|
||||||
|
companyId: string,
|
||||||
|
type: "logo" | "stamp",
|
||||||
|
ext: string
|
||||||
|
): string {
|
||||||
|
return `company-assets/${companyId}/${type}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a file buffer directly to storage (server-side).
|
* Upload a file buffer directly to storage (server-side).
|
||||||
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -125,6 +125,8 @@ model Company {
|
||||||
email String?
|
email String?
|
||||||
invoiceEmail String?
|
invoiceEmail String?
|
||||||
invoiceAddress String?
|
invoiceAddress String?
|
||||||
|
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||||||
|
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue