'use client'; import 'react-image-crop/dist/ReactCrop.css'; import { useEffect, useRef, useState } from 'react'; import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop'; import { Loader2, RefreshCw, Trash2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { toastError } from '@/lib/api/toast-error'; import { toast } from 'sonner'; import { useConfirmation } from '@/hooks/use-confirmation'; const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif'; type AspectMode = 'wide' | 'square' | 'freeform'; const ASPECT_RATIOS: Record = { wide: 3, square: 1, freeform: undefined, }; interface CurrentLogo { fileId: string; previewUrl: string; sizeBytes: string | null; mimeType: string | null; } interface UploadResult { fileId: string; previewUrl: string; warnings: string[]; finalDimensions: { width: number; height: number }; finalBytes: number; originalDimensions: { width: number; height: number }; originalFormat: string; } const WARNING_LABELS: Record = { trimmed: 'Auto-trimmed whitespace borders', resized: 'Downscaled for size', 'no-alpha': 'No transparent background — will show a white box on dark headers', 'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering', 'svg-rasterized': 'SVG rasterized to PNG at 300 DPI', 'heic-converted': 'HEIC/HEIF converted to PNG', 'webp-converted': 'WebP converted to PNG', }; function centeredCrop(width: number, height: number, aspect: number): Crop { return centerCrop(makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height); } export function PdfLogoUploader() { const { confirm, dialog: confirmDialog } = useConfirmation(); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); const [file, setFile] = useState(null); const [objectUrl, setObjectUrl] = useState(null); const [crop, setCrop] = useState(); const [completedCrop, setCompletedCrop] = useState(null); const [aspectMode, setAspectMode] = useState('wide'); const [warnings, setWarnings] = useState([]); const [uploadInfo, setUploadInfo] = useState(null); const imgRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { void refresh(); }, []); useEffect(() => { return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [objectUrl]); async function refresh() { setLoading(true); try { const res = await fetch('/api/v1/admin/branding/logo'); if (!res.ok) throw new Error(`GET ${res.status}`); const body = (await res.json()) as { data: CurrentLogo | null }; setCurrent(body.data ?? null); } catch (err) { toastError(err); } finally { setLoading(false); } } function pick(f: File | null) { if (!f) return; if (objectUrl) URL.revokeObjectURL(objectUrl); const url = URL.createObjectURL(f); setFile(f); setObjectUrl(url); setCrop(undefined); setCompletedCrop(null); setWarnings([]); setUploadInfo(null); } function onImageLoad(e: React.SyntheticEvent) { const { naturalWidth: w, naturalHeight: h } = e.currentTarget; const aspect = ASPECT_RATIOS[aspectMode]; if (aspect) setCrop(centeredCrop(w, h, aspect)); } function changeAspect(mode: AspectMode) { setAspectMode(mode); const img = imgRef.current; const aspect = ASPECT_RATIOS[mode]; if (img && aspect) { setCrop(centeredCrop(img.naturalWidth, img.naturalHeight, aspect)); } else { setCrop(undefined); } } async function upload() { if (!file) return; setWorking(true); try { const fd = new FormData(); fd.append('file', file); if (completedCrop && completedCrop.width > 0 && completedCrop.height > 0) { fd.append( 'crop', JSON.stringify({ x: completedCrop.x, y: completedCrop.y, width: completedCrop.width, height: completedCrop.height, }), ); } const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd }); const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } }; if (!res.ok) { const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`; throw new Error(message); } const data = (body as { data: UploadResult }).data; setUploadInfo(data); setWarnings(data.warnings ?? []); toast.success('Logo saved'); await refresh(); // Clear the local crop staging so the preview now shows the stored logo. setFile(null); if (objectUrl) URL.revokeObjectURL(objectUrl); setObjectUrl(null); } catch (err) { toastError(err); } finally { setWorking(false); } } async function clear() { const ok = await confirm({ title: 'Remove PDF logo', description: 'Remove the PDF logo? Future reports will fall back to the port name.', confirmLabel: 'Remove', }); if (!ok) return; setWorking(true); try { const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' }); if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`); toast.success('Logo removed'); setCurrent(null); setUploadInfo(null); setWarnings([]); } catch (err) { toastError(err); } finally { setWorking(false); } } function openSamplePdf() { window.open('/api/v1/admin/branding/logo/sample-pdf', '_blank'); } return ( PDF logo Used as the header logo on every internal PDF the CRM generates (reports, expense sheets, record exports). Separate from the email/UI logo so PDFs can have a higher-resolution, alpha-channel version optimized for print.

Use PNG or SVG with a transparent background.

Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square).

Max 5MB; we'll auto-trim, downscale, and convert to PNG.

Avoid JPEGs unless the background is solid white.

{loading ? (
Loading current logo…
) : current ? (
{/* eslint-disable-next-line @next/next/no-img-element */} Current PDF logo on dark header
Current logo
{current.sizeBytes ? `${(Number(current.sizeBytes) / 1024).toFixed(1)} KB` : 'size unknown'}{' '} · {current.mimeType ?? 'image'}
) : (
No PDF logo configured yet. Reports use the port name as a text header.
)}
pick(e.target.files?.[0] ?? null)} className="block w-full text-sm file:mr-3 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground" />
{objectUrl ? (
Crop: {(['wide', 'square', 'freeform'] as const).map((m) => ( ))}
setCrop(percent)} onComplete={(c) => setCompletedCrop(c)} aspect={ASPECT_RATIOS[aspectMode]} className="max-h-[400px]" > {/* eslint-disable-next-line @next/next/no-img-element */} Logo to crop
) : null} {uploadInfo ? (
Processed
From {uploadInfo.originalFormat.toUpperCase()} {uploadInfo.originalDimensions.width}× {uploadInfo.originalDimensions.height} {' → '} PNG {uploadInfo.finalDimensions.width}×{uploadInfo.finalDimensions.height} ( {(uploadInfo.finalBytes / 1024).toFixed(1)} KB)
{warnings.length > 0 ? (
    {warnings.map((w) => (
  • {WARNING_LABELS[w] ?? w}
  • ))}
) : null}
) : null} {confirmDialog}
); }