mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(invoices): M3 logo upload — embed in PDF header
Completes the Settings polish item left open after M2. pdf/logo.ts - loadLogo(mediaId): fetches the large variant from mana-media, sniffs content-type to pick 'png' vs 'jpg', returns null on any failure so the PDF still renders without a logo - uploadLogo(file): multipart POST to /api/v1/media/upload with app=invoices, returns the new mediaId (or throws a user-facing msg) - logoPreviewUrl(mediaId): thin helper so the settings form doesn't have to know the media-URL lookup pattern Renderer wiring - loadLogo runs in the same Promise.all as font embedding so it doesn't add a serial wait - embedPng / embedJpg based on the sniffed kind; errors degrade silently - renderHeader takes a PDFImage|null and, when present, draws it top- left above the sender name, max 25mm × 45% content-width, aspect preserved, 3mm breathing room below Settings UI (SenderProfileForm) - Logo slot at the top of the Absender section: preview when set, "Ersetzen" / "Entfernen" actions; "+ Logo hochladen" drop-style button when empty - Upload persists immediately (no separate "Speichern" click for logo changes) — keeps the interaction one-handed - Accepts PNG / JPEG; invalid types rejected client-side before the network round-trip Closes one of the open items from docs/plans/invoices-module.md §M3. Next open: M8 AI-tools (create_invoice / mark_paid / list / stats). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b7564b3a4
commit
76060b0632
3 changed files with 301 additions and 5 deletions
|
|
@ -6,10 +6,49 @@
|
|||
import { invoiceSettingsStore } from '../stores/settings.svelte';
|
||||
import type { InvoiceSettings, Currency } from '../types';
|
||||
import { VAT_RATES_CH, VAT_RATES_DE, CURRENCIES } from '../constants';
|
||||
import { uploadLogo, logoPreviewUrl } from '../pdf/logo';
|
||||
|
||||
let settings = $state<InvoiceSettings | null>(null);
|
||||
let saving = $state(false);
|
||||
let savedAt = $state<string | null>(null);
|
||||
let uploadingLogo = $state(false);
|
||||
let logoError = $state<string | null>(null);
|
||||
let logoInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
async function onLogoSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !settings) return;
|
||||
uploadingLogo = true;
|
||||
logoError = null;
|
||||
try {
|
||||
const mediaId = await uploadLogo(file);
|
||||
// Persist immediately — user doesn't need to hit "Speichern" separately
|
||||
// for logo changes. The in-memory settings state stays in sync so
|
||||
// further edits don't lose the mediaId.
|
||||
await invoiceSettingsStore.update({ logoMediaId: mediaId });
|
||||
settings.logoMediaId = mediaId;
|
||||
} catch (e) {
|
||||
logoError = e instanceof Error ? e.message : 'Upload fehlgeschlagen';
|
||||
} finally {
|
||||
uploadingLogo = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLogo() {
|
||||
if (!settings) return;
|
||||
uploadingLogo = true;
|
||||
logoError = null;
|
||||
try {
|
||||
await invoiceSettingsStore.update({ logoMediaId: null });
|
||||
settings.logoMediaId = null;
|
||||
} catch (e) {
|
||||
logoError = e instanceof Error ? e.message : 'Entfernen fehlgeschlagen';
|
||||
} finally {
|
||||
uploadingLogo = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
invoiceSettingsStore.get().then((s) => {
|
||||
|
|
@ -66,6 +105,56 @@
|
|||
<h3>Absender</h3>
|
||||
<p class="hint">Erscheint im Kopf jeder Rechnung.</p>
|
||||
|
||||
<div class="field logo-field">
|
||||
<span>Logo</span>
|
||||
<div class="logo-row">
|
||||
{#if settings.logoMediaId}
|
||||
<img
|
||||
class="logo-preview"
|
||||
src={logoPreviewUrl(settings.logoMediaId)}
|
||||
alt="Logo-Vorschau"
|
||||
/>
|
||||
<div class="logo-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm"
|
||||
onclick={() => logoInput?.click()}
|
||||
disabled={uploadingLogo}
|
||||
>
|
||||
Ersetzen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-sm btn-danger"
|
||||
onclick={removeLogo}
|
||||
disabled={uploadingLogo}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="logo-drop"
|
||||
onclick={() => logoInput?.click()}
|
||||
disabled={uploadingLogo}
|
||||
>
|
||||
{uploadingLogo ? 'Lädt hoch …' : '+ Logo hochladen'}
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={logoInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
hidden
|
||||
onchange={onLogoSelect}
|
||||
/>
|
||||
</div>
|
||||
{#if logoError}
|
||||
<span class="hint-warn">{logoError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Name *</span>
|
||||
<input type="text" bind:value={settings.senderName} required />
|
||||
|
|
@ -291,4 +380,75 @@
|
|||
font-size: 0.85rem;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.logo-field {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
max-width: 140px;
|
||||
max-height: 64px;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.3rem;
|
||||
padding: 0.25rem;
|
||||
background: white;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.logo-drop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: 1px dashed var(--color-border, #e2e8f0);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface-muted, #f8fafc);
|
||||
color: var(--color-text-muted, #64748b);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logo-drop:hover:not(:disabled) {
|
||||
border-color: #059669;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.logo-drop:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm.btn-danger {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.hint-warn {
|
||||
font-size: 0.8rem;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
88
apps/mana/apps/web/src/lib/modules/invoices/pdf/logo.ts
Normal file
88
apps/mana/apps/web/src/lib/modules/invoices/pdf/logo.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Logo loader for the PDF renderer.
|
||||
*
|
||||
* Fetches the user's logo from mana-media by `mediaId`, sniffs the MIME
|
||||
* type from the response so we route to pdf-lib's `embedPng` vs
|
||||
* `embedJpg` correctly, and returns a compact { bytes, kind } tuple.
|
||||
*
|
||||
* Errors are intentionally non-throwing — a missing / broken logo
|
||||
* shouldn't prevent the invoice PDF from rendering. Caller treats the
|
||||
* null return as "skip the logo slot".
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/** Resolve the mana-media base URL the same way other modules do. */
|
||||
function getMediaUrl(): string {
|
||||
if (browser) {
|
||||
const fromWindow = (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string })
|
||||
.__PUBLIC_MANA_MEDIA_URL__;
|
||||
if (fromWindow) return fromWindow;
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
|
||||
}
|
||||
|
||||
export type LogoKind = 'png' | 'jpg';
|
||||
|
||||
export interface LoadedLogo {
|
||||
bytes: Uint8Array;
|
||||
kind: LogoKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the logo large variant and return the bytes + the format so the
|
||||
* renderer can pick the right pdf-lib embed function. Returns null on
|
||||
* any failure (offline, 404, unsupported format).
|
||||
*/
|
||||
export async function loadLogo(mediaId: string | null | undefined): Promise<LoadedLogo | null> {
|
||||
if (!mediaId) return null;
|
||||
const url = `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const kind: LogoKind | null = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('jpeg') || contentType.includes('jpg')
|
||||
? 'jpg'
|
||||
: null;
|
||||
if (!kind) return null;
|
||||
const buf = await res.arrayBuffer();
|
||||
return { bytes: new Uint8Array(buf), kind };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to mana-media under `app=invoices` so it's scoped and
|
||||
* discoverable. Returns the new mediaId on success, or throws with a
|
||||
* user-facing message on failure (caller shows the error inline).
|
||||
*
|
||||
* Lives here rather than in a store because the upload is a one-shot
|
||||
* side effect — the setting write that follows it is the only sync-
|
||||
* tracked state change.
|
||||
*/
|
||||
export async function uploadLogo(file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('Bitte wähle ein Bild aus (PNG oder JPG).');
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('app', 'invoices');
|
||||
const res = await fetch(`${getMediaUrl()}/api/v1/media/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
const data = (await res.json()) as { id: string };
|
||||
if (!data.id) throw new Error('Upload-Antwort ohne Media-ID.');
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** Convenience for the UI preview. Large variant matches what the PDF uses. */
|
||||
export function logoPreviewUrl(mediaId: string): string {
|
||||
return `${getMediaUrl()}/api/v1/media/${mediaId}/file/large`;
|
||||
}
|
||||
|
|
@ -33,7 +33,14 @@
|
|||
* Inter/Roboto via fetch in M7 when we care about brand typography.
|
||||
*/
|
||||
|
||||
import { PDFDocument, StandardFonts, rgb, type PDFPage, type PDFFont } from 'pdf-lib';
|
||||
import {
|
||||
PDFDocument,
|
||||
StandardFonts,
|
||||
rgb,
|
||||
type PDFPage,
|
||||
type PDFFont,
|
||||
type PDFImage,
|
||||
} from 'pdf-lib';
|
||||
import type { Invoice, InvoiceSettings } from '../types';
|
||||
import { CURRENCIES } from '../constants';
|
||||
import {
|
||||
|
|
@ -45,8 +52,10 @@ import {
|
|||
LINE_COLS,
|
||||
SPACE,
|
||||
LINE_HEIGHT,
|
||||
mm,
|
||||
} from './templates/default';
|
||||
import { attachQRBillToPdf, buildQRBillData, QRBillError } from './qr-bill';
|
||||
import { loadLogo } from './logo';
|
||||
|
||||
// ─── Small geometry helpers ────────────────────────────────
|
||||
|
||||
|
|
@ -178,9 +187,33 @@ function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string
|
|||
|
||||
// ─── Section renderers ────────────────────────────────────
|
||||
|
||||
function renderHeader(ctx: RenderContext, invoice: Invoice, settings: InvoiceSettings): number {
|
||||
// Left: sender
|
||||
function renderHeader(
|
||||
ctx: RenderContext,
|
||||
invoice: Invoice,
|
||||
settings: InvoiceSettings,
|
||||
logo: PDFImage | null
|
||||
): number {
|
||||
let leftY = ctx.y;
|
||||
|
||||
// Logo sits top-left, above the sender block. Max 25mm tall, 45% of
|
||||
// content-width wide; aspect ratio preserved (scale by the tighter
|
||||
// constraint). Leaves ~3mm breathing room before sender name.
|
||||
if (logo) {
|
||||
const maxH = mm(25);
|
||||
const maxW = ctx.contentWidth * 0.45;
|
||||
const { width: naturalW, height: naturalH } = logo.scale(1);
|
||||
const scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
|
||||
const w = naturalW * scale;
|
||||
const h = naturalH * scale;
|
||||
ctx.page.drawImage(logo, {
|
||||
x: ctx.leftX,
|
||||
y: leftY - h,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
leftY -= h + mm(3);
|
||||
}
|
||||
|
||||
drawText(ctx, settings.senderName || '—', ctx.leftX, leftY, {
|
||||
size: FONT_SIZE.brand,
|
||||
font: ctx.bold,
|
||||
|
|
@ -508,11 +541,26 @@ export async function renderInvoicePdf(
|
|||
doc.setProducer('pdf-lib');
|
||||
doc.setCreationDate(new Date());
|
||||
|
||||
const [regular, bold] = await Promise.all([
|
||||
const [regular, bold, logoData] = await Promise.all([
|
||||
doc.embedFont(StandardFonts.Helvetica),
|
||||
doc.embedFont(StandardFonts.HelveticaBold),
|
||||
loadLogo(settings.logoMediaId),
|
||||
]);
|
||||
|
||||
// Embed the logo once (shared image resource across any future pages
|
||||
// that draw it). Silent on failure — missing logos just skip the slot.
|
||||
let logo: PDFImage | null = null;
|
||||
if (logoData) {
|
||||
try {
|
||||
logo =
|
||||
logoData.kind === 'png'
|
||||
? await doc.embedPng(logoData.bytes)
|
||||
: await doc.embedJpg(logoData.bytes);
|
||||
} catch {
|
||||
logo = null;
|
||||
}
|
||||
}
|
||||
|
||||
const page = doc.addPage([A4.width, A4.height]);
|
||||
const leftX = MARGIN.left;
|
||||
const rightX = A4.width - MARGIN.right;
|
||||
|
|
@ -528,7 +576,7 @@ export async function renderInvoicePdf(
|
|||
contentWidth: rightX - leftX,
|
||||
};
|
||||
|
||||
ctx.y = renderHeader(ctx, invoice, settings);
|
||||
ctx.y = renderHeader(ctx, invoice, settings, logo);
|
||||
ctx.y = renderRecipient(ctx, invoice, ctx.y);
|
||||
ctx.y = renderSubject(ctx, invoice, ctx.y);
|
||||
const linesResult = renderLinesTable(ctx, invoice);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue