Author SHA1 Message Date
odweta 879d8c7df5 Merge remote-tracking branch 'origin/main' into odweta-urban-goggles
# Conflicts:
#	app/src/app.js
2026-09-10 17:26:45 +02:00
odwetaandCopilot App d73eb10416 Add optional signature upload to invoice PDF
Adds a drag-and-drop/click upload field on the invoice editor for an
optional 420x210 JPG/PNG signature image. The signature is stored as
part of invoice_data and rendered on the generated PDF directly above
the QR code, right-aligned with sensible margins.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:25:37 +02:00
odweta eb48faa6b6 Merge pull request #6 from odweta/odweta-ideal-spork
Allow user-configurable invoice numbering start
2026-09-10 17:23:57 +02:00
odweta 4a2b59f45e Merge pull request #4 from odweta/odweta-align-pdf-payment-footer
Anchor PDF payment card to footer and size QR to payment lines
2026-09-10 17:07:03 +02:00
odwetaandCopilot App 51e0182399 Anchor PDF payment card to footer and size QR to text height
Move the payment info card (account number, IBAN, SWIFT) to the
bottom of the invoice page so it sits flush with the footer, and
size the QR code to match the combined height of the three payment
lines instead of a fixed size.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:06:13 +02:00
6 changed files with 228 additions and 7 deletions
+23 -7
View File
@@ -10,7 +10,7 @@ const { normalizeInvoiceData } = require('./invoice-data');
const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
app.use(express.json());
app.use(express.json({ limit: '2mb' }));
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => new Promise((resolve, reject) => {
crypto.scrypt(password, salt, 64, (error, derivedKey) => {
@@ -535,6 +535,7 @@ const renderInvoicePdf = async (document, invoice) => {
width: 180,
margin: 1
}) : null;
const signatureBuffer = data.signature ? Buffer.from(data.signature.split(',')[1], 'base64') : null;
const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right;
const navy = '#183b56';
const line = '#d8d4cc';
@@ -607,16 +608,31 @@ const renderInvoicePdf = async (document, invoice) => {
document.y += 35;
document.moveTo(document.page.margins.left, document.y).lineTo(document.page.margins.left + pageWidth, document.y)
.strokeColor(navy).lineWidth(1.5).stroke();
document.y += 12;
const paymentTop = document.y;
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, 165, 4).fillAndStroke('#f4f7fb', line);
const paymentCardHeight = 105;
const paymentTop = document.page.height - document.page.margins.bottom - paymentCardHeight;
const paymentLineHeight = 18;
const qrSize = paymentLineHeight * 3;
if (signatureBuffer) {
const signatureWidth = 145;
const signatureHeight = signatureWidth / 2;
const signatureGap = 8;
const signatureX = document.page.margins.left + pageWidth - signatureWidth;
document.image(signatureBuffer, signatureX, paymentTop - signatureGap - signatureHeight, {
fit: [signatureWidth, signatureHeight]
});
}
document.roundedRect(document.page.margins.left, paymentTop, pageWidth, paymentCardHeight, 4)
.fillAndStroke('#f4f7fb', line);
document.fillColor(navy).font(boldFont).fontSize(11).text('Platební údaje', document.page.margins.left + 10, paymentTop + 10);
document.fillColor(bodyText).font(regularFont).fontSize(10)
.text(`Číslo účtu: ${pdfText(payment.cisloUctu)}`, document.page.margins.left + 10, paymentTop + 35)
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 55)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 75);
.text(`IBAN: ${pdfText(payment.iban)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight)
.text(`SWIFT: ${pdfText(payment.swift)}`, document.page.margins.left + 10, paymentTop + 35 + paymentLineHeight * 2);
if (qrBuffer) {
document.image(qrBuffer, document.page.margins.left + pageWidth - 155, paymentTop + 10, { width: 145, height: 145 });
document.image(qrBuffer, document.page.margins.left + pageWidth - qrSize - 10, paymentTop + 30, {
width: qrSize,
height: qrSize
});
}
document.end();
};
+7
View File
@@ -1,3 +1,5 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) {
return null;
@@ -16,11 +18,16 @@ const normalizeInvoiceData = (data) => {
return null;
}
const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature)
? data.signature
: null;
return {
supplier: data.supplier || {},
customer: data.customer || {},
payment: data.payment || {},
items,
signature,
total: items.reduce((sum, item) => sum + item.mnozstvi * item.cenaZaMj, 0)
};
};
+15
View File
@@ -335,6 +335,21 @@
</div>
</div>
<div class="form-chapter signature-row">
<div class="form-group signature-group">
<label for="signatureInput">Podpis (nepovinné)</label>
<div id="signatureDropzone" class="signature-dropzone" tabindex="0" role="button" aria-label="Nahrát podpis kliknutím nebo přetažením souboru">
<img id="signaturePreview" class="signature-preview" alt="Náhled podpisu" hidden />
<p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p>
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div>
<div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</div>
<p id="signatureStatus" class="signature-status" role="status" aria-live="polite"></p>
</div>
</div>
<button type="button" id="printInvoiceButton">
Vygenerovat a vytisknout fakturu
</button>
+98
View File
@@ -51,6 +51,12 @@ const dueDateDisplay = document.getElementById("dueDateDisplay");
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName");
const savePaymentPresetButton = document.getElementById("savePaymentPresetButton");
const signatureDropzone = document.getElementById("signatureDropzone");
const signatureInput = document.getElementById("signatureInput");
const signaturePreview = document.getElementById("signaturePreview");
const signatureDropzoneText = document.getElementById("signatureDropzoneText");
const removeSignatureButton = document.getElementById("removeSignatureButton");
const signatureStatus = document.getElementById("signatureStatus");
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -74,6 +80,93 @@ let invoices = [];
let paymentPresets = [];
let currentInvoiceId = null;
let isRegistrationMode = false;
let signatureDataUrl = null;
const SIGNATURE_WIDTH = 420;
const SIGNATURE_HEIGHT = 210;
const setSignatureStatus = (message, isError = false) => {
signatureStatus.textContent = message || "";
signatureStatus.classList.toggle("is-error", Boolean(isError));
};
const applySignaturePreview = (dataUrl) => {
signatureDataUrl = dataUrl || null;
if (signatureDataUrl) {
signaturePreview.src = signatureDataUrl;
signaturePreview.hidden = false;
signatureDropzoneText.hidden = true;
removeSignatureButton.hidden = false;
} else {
signaturePreview.hidden = true;
signaturePreview.src = "";
signatureDropzoneText.hidden = false;
removeSignatureButton.hidden = true;
}
};
const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () => reject(new Error("Obrázek se nepodařilo načíst."));
image.src = dataUrl;
});
const handleSignatureFile = async (file) => {
if (!file) {
return;
}
if (!["image/png", "image/jpeg"].includes(file.type)) {
setSignatureStatus("Podporovány jsou pouze soubory JPG nebo PNG.", true);
return;
}
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error("Soubor se nepodařilo načíst."));
reader.readAsDataURL(file);
});
const { width, height } = await readImageDimensions(dataUrl);
if (width !== SIGNATURE_WIDTH || height !== SIGNATURE_HEIGHT) {
setSignatureStatus(`Obrázek musí mít rozměry přesně ${SIGNATURE_WIDTH}×${SIGNATURE_HEIGHT} px (nahráno ${width}×${height} px).`, true);
return;
}
applySignaturePreview(dataUrl);
setSignatureStatus("Podpis byl nahrán.");
} catch (error) {
setSignatureStatus(error.message, true);
}
};
signatureDropzone.addEventListener("click", () => signatureInput.click());
signatureDropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
signatureInput.click();
}
});
signatureDropzone.addEventListener("dragover", (event) => {
event.preventDefault();
signatureDropzone.classList.add("is-dragover");
});
signatureDropzone.addEventListener("dragleave", () => {
signatureDropzone.classList.remove("is-dragover");
});
signatureDropzone.addEventListener("drop", (event) => {
event.preventDefault();
signatureDropzone.classList.remove("is-dragover");
handleSignatureFile(event.dataTransfer.files[0]);
});
signatureInput.addEventListener("change", () => {
handleSignatureFile(signatureInput.files[0]);
signatureInput.value = "";
});
removeSignatureButton.addEventListener("click", (event) => {
event.stopPropagation();
applySignaturePreview(null);
setSignatureStatus("");
});
const profileFieldMap = {
nazevSpolecnosti: "NazevSpolecnosti",
@@ -253,6 +346,7 @@ const readInvoiceData = () => ({
iban: getValue("iban"),
swift: getValue("swift")
},
signature: signatureDataUrl,
total: Number.parseFloat(getValue("total").replace(",", ".")) || 0,
items: Array.from(itemsContainer.querySelectorAll(".invoice-item")).map((item) => {
const inputs = item.querySelectorAll("input");
@@ -279,6 +373,8 @@ const clearInvoiceEditor = () => {
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = "";
});
applySignaturePreview(null);
setSignatureStatus("");
itemsContainer.replaceChildren();
itemIndex = 0;
updateTotal();
@@ -300,6 +396,8 @@ const loadInvoiceIntoEditor = (invoice) => {
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = invoice.data.payment?.[id] || "";
});
applySignaturePreview(invoice.data.signature || null);
setSignatureStatus("");
itemsContainer.replaceChildren();
itemIndex = 0;
(invoice.data.items || []).forEach((item) => addInvoiceItem(item));
+66
View File
@@ -627,6 +627,72 @@ body {
gap: 2rem;
}
.signature-row {
align-items: flex-start;
}
.signature-group {
width: 100%;
max-width: 420px;
}
.signature-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 120px;
padding: 1rem;
text-align: center;
color: var(--muted-ink);
background: #fff;
border: 2px dashed #c8d0d5;
border-radius: 0.5rem;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.signature-dropzone:hover,
.signature-dropzone:focus-visible {
border-color: var(--blue);
outline: none;
}
.signature-dropzone.is-dragover {
border-color: var(--blue);
background: rgba(43, 111, 143, 0.08);
}
.signature-dropzone-text {
margin: 0;
font-size: 0.85rem;
line-height: 1.4;
}
.signature-preview {
display: block;
max-width: 100%;
max-height: 130px;
object-fit: contain;
border-radius: 0.25rem;
}
.signature-actions {
margin-top: 0.5rem;
}
.signature-status {
margin: 0.4rem 0 0;
min-height: 1.1rem;
color: var(--muted-ink);
font-size: 0.8rem;
}
.signature-status.is-error {
color: #b3261e;
}
.total-amount-group {
grid-column: 1 / -1;
margin: 0;
+19
View File
@@ -30,4 +30,23 @@ describe('normalizeInvoiceData', () => {
{ mnozstvi: 'not-a-number', cenaZaMj: 10 }
] })).toBeNull();
});
test('accepts a valid signature data URL and rejects invalid ones', () => {
const withValidSignature = normalizeInvoiceData({
items: [{ mnozstvi: 1, cenaZaMj: 10 }],
signature: 'data:image/png;base64,aGVsbG8='
});
expect(withValidSignature.signature).toBe('data:image/png;base64,aGVsbG8=');
const withInvalidSignature = normalizeInvoiceData({
items: [{ mnozstvi: 1, cenaZaMj: 10 }],
signature: 'not-a-data-url'
});
expect(withInvalidSignature.signature).toBeNull();
const withoutSignature = normalizeInvoiceData({
items: [{ mnozstvi: 1, cenaZaMj: 10 }]
});
expect(withoutSignature.signature).toBeNull();
});
});