diff --git a/App/app/(portal)/admin/companies/actions.ts b/App/app/(portal)/admin/companies/actions.ts index f1c81cf..a0c867b 100644 --- a/App/app/(portal)/admin/companies/actions.ts +++ b/App/app/(portal)/admin/companies/actions.ts @@ -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 = { + "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 { return { ok: true }; } +// ── Branding assets (logo + stamp) ────────────────────────────────────────────── + +export async function uploadCompanyAsset(formData: FormData): Promise { + 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 { + 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 { const session = await auth(); if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { diff --git a/App/app/(portal)/admin/companies/companies-table.tsx b/App/app/(portal)/admin/companies/companies-table.tsx index b0516fa..167df7d 100644 --- a/App/app/(portal)/admin/companies/companies-table.tsx +++ b/App/app/(portal)/admin/companies/companies-table.tsx @@ -18,6 +18,8 @@ export type CompanyRow = { email: string | null; invoiceEmail: string | null; invoiceAddress: string | null; + logoUrl: string | null; + stampUrl: string | null; isActive: boolean; }; diff --git a/App/app/(portal)/admin/companies/company-branding-uploader.tsx b/App/app/(portal)/admin/companies/company-branding-uploader.tsx new file mode 100644 index 0000000..58f2be6 --- /dev/null +++ b/App/app/(portal)/admin/companies/company-branding-uploader.tsx @@ -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(null); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(""); + + function handleFileChange(e: React.ChangeEvent) { + 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 ( +
+
+

{label}

+ {currentUrl && !preview && ( + + )} +
+ + {displayUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {label} + {preview &&

Preview — not yet saved

} +
+ )} + +
inputRef.current?.click()} + > + +

Click to select image

+

{hint}

+ +
+ + {error &&

{error}

} + + {preview && ( + + )} +
+ ); +} diff --git a/App/app/(portal)/admin/companies/company-form.tsx b/App/app/(portal)/admin/companies/company-form.tsx index ba44318..293548d 100644 --- a/App/app/(portal)/admin/companies/company-form.tsx +++ b/App/app/(portal)/admin/companies/company-form.tsx @@ -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 }) {