Merge pull request 'feat(po): per-company logo, stamp & brand bar on exported POs' (#48) from feat/company-header-logo-branding into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #48
This commit is contained in:
commit
74d20cd452
11 changed files with 480 additions and 20 deletions
|
|
@ -3,11 +3,21 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
// Branding assets (logo + stamp) shown on exported POs.
|
||||
const ASSET_MIME: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const ASSET_MAX_BYTES = 4 * 1024 * 1024; // 4 MB — banners/seals can be larger than signatures
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1, "Company name is required"),
|
||||
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
||||
|
|
@ -98,6 +108,58 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Branding assets (logo + stamp) ──────────────────────────────────────────────
|
||||
|
||||
export async function uploadCompanyAsset(formData: FormData): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const companyId = formData.get("companyId") as string | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
if (!companyId) return { error: "Company ID is required" };
|
||||
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||
|
||||
const company = await db.company.findUnique({ where: { id: companyId }, select: { id: true } });
|
||||
if (!company) return { error: "Company not found" };
|
||||
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { error: "No file provided" };
|
||||
if (file.size > ASSET_MAX_BYTES) return { error: "Image must be under 4 MB" };
|
||||
|
||||
const ext = ASSET_MIME[file.type];
|
||||
if (!ext) return { error: "Image must be a PNG, JPG, or WebP" };
|
||||
|
||||
const key = buildCompanyAssetKey(companyId, type, ext);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await uploadBuffer(key, buffer, file.type);
|
||||
|
||||
await db.company.update({
|
||||
where: { id: companyId },
|
||||
data: type === "logo" ? { logoKey: key } : { stampKey: key },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeCompanyAsset(companyId: string, type: "logo" | "stamp"): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||
|
||||
await db.company.update({
|
||||
where: { id: companyId },
|
||||
data: type === "logo" ? { logoKey: null } : { stampKey: null },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/companies");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type CompanyRow = {
|
|||
email: string | null;
|
||||
invoiceEmail: string | null;
|
||||
invoiceAddress: string | null;
|
||||
logoUrl: string | null;
|
||||
stampUrl: string | null;
|
||||
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 { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { createCompany, updateCompany } from "./actions";
|
||||
import { CompanyBrandingUploader } from "./company-branding-uploader";
|
||||
|
||||
type CompanyRow = {
|
||||
id: string;
|
||||
|
|
@ -16,6 +17,8 @@ type CompanyRow = {
|
|||
email: string | null;
|
||||
invoiceEmail: string | null;
|
||||
invoiceAddress: string | null;
|
||||
logoUrl: string | null;
|
||||
stampUrl: string | null;
|
||||
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>
|
||||
<textarea name="invoiceAddress" defaultValue={company?.invoiceAddress ?? ""} rows={2} className={INPUT} placeholder="Full address as it should appear on invoices/POs" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { generateDownloadUrl } from "@/lib/storage";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CompaniesTable } from "./companies-table";
|
||||
import type { Metadata } from "next";
|
||||
|
|
@ -16,21 +17,23 @@ export default async function AdminCompaniesPage() {
|
|||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<CompaniesTable
|
||||
companies={companies.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
gstNumber: c.gstNumber,
|
||||
address: c.address,
|
||||
telephone: c.telephone,
|
||||
mobile: c.mobile,
|
||||
email: c.email,
|
||||
invoiceEmail: c.invoiceEmail,
|
||||
invoiceAddress: c.invoiceAddress,
|
||||
isActive: c.isActive,
|
||||
}))}
|
||||
/>
|
||||
const rows = await Promise.all(
|
||||
companies.map(async (c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
gstNumber: c.gstNumber,
|
||||
address: c.address,
|
||||
telephone: c.telephone,
|
||||
mobile: c.mobile,
|
||||
email: c.email,
|
||||
invoiceEmail: c.invoiceEmail,
|
||||
invoiceAddress: c.invoiceAddress,
|
||||
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
|
||||
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
|
||||
isActive: c.isActive,
|
||||
}))
|
||||
);
|
||||
|
||||
return <CompaniesTable companies={rows} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ function fmtNum(n: number, dec = 2): string {
|
|||
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
}
|
||||
|
||||
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
|
||||
const BRAND_BAR_COLOR = "#92D050";
|
||||
|
||||
function mimeForKey(key: string): string {
|
||||
const ext = key.split(".").pop()?.toLowerCase();
|
||||
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
piQuotationNo?: string | null; piQuotationDate?: 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.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 ══════════════════════════════════════════════
|
||||
ws.getRow(5).height = 18;
|
||||
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 + 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 });
|
||||
|
|
@ -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 });
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const slug = po.poNumber.replace(/\//g, "-");
|
||||
|
|
@ -506,9 +560,20 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
color: #111;
|
||||
margin: 10mm 12mm;
|
||||
line-height: 1.3;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header-band { position: relative; }
|
||||
.co-logo {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
max-height: 52px;
|
||||
max-width: 92px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.co-name {
|
||||
text-align: center;
|
||||
font-size: 13pt;
|
||||
|
|
@ -568,6 +633,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
/* ── Signatures ── */
|
||||
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||
.sig-box {
|
||||
position: relative;
|
||||
border: 1px solid #999;
|
||||
width: 44%;
|
||||
min-height: 60px;
|
||||
|
|
@ -579,9 +645,26 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
}
|
||||
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
||||
.sig-sub { font-size: 7.5pt; }
|
||||
.sig-stamp {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
max-height: 66px;
|
||||
max-width: 88px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spacer { margin: 4px 0; }
|
||||
|
||||
/* ── Brand bar (bottom) ── */
|
||||
.brand-bar {
|
||||
height: 14px;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
background: ${BRAND_BAR_COLOR};
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
body { margin: 8mm 10mm; }
|
||||
|
|
@ -598,9 +681,12 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
<div class="header-band">
|
||||
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
||||
<div class="co-name">${CO_NAME}</div>
|
||||
<div class="co-addr">${CO_ADDR}</div>
|
||||
<div class="co-tel">${CO_TEL}</div>
|
||||
</div>
|
||||
<div class="po-title">PURCHASE ORDER</div>
|
||||
|
||||
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||
|
|
@ -718,6 +804,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||
<div class="sig">
|
||||
<div class="sig-box">
|
||||
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
||||
${signatureBase64
|
||||
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
||||
: `<div class="sig-name">${approvedBy}</div>`
|
||||
|
|
@ -725,7 +812,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
<div>
|
||||
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
||||
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
||||
<div class="sig-sub">For, ${CO_NAME}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-box">
|
||||
|
|
@ -737,6 +824,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||
<div class="brand-bar"></div>
|
||||
|
||||
<script>window.onload = function() { window.print(); };</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
|||
return `signatures/${userId}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a company branding asset (logo or stamp/seal).
|
||||
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||
*/
|
||||
export function buildCompanyAssetKey(
|
||||
companyId: string,
|
||||
type: "logo" | "stamp",
|
||||
ext: string
|
||||
): string {
|
||||
return `company-assets/${companyId}/${type}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file buffer directly to storage (server-side).
|
||||
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
invoiceEmail String?
|
||||
invoiceAddress String?
|
||||
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||||
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
|
|||
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