Author SHA1 Message Date
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
odweta 5882a957ad Merge pull request #3 from odweta/odweta-fictional-barnacle
Add CI/CD GitHub Actions workflow for dev image deploys
2026-09-10 16:06:28 +02:00
odwetaandCopilot App f4d2c30493 Add CI/CD GitHub Actions workflow for dev image builds
- Run tests on push/PR to main
- Build and push Docker image to GHCR as :dev and :dev-<sha> on push to main
- Document image pull/run instructions in README

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 16:05:06 +02:00
odweta 3848ab47df Merge pull request #2 from odweta/odweta-invoice-pdf-styling
Unify manual and ZIP-exported invoice PDF styling
2026-09-10 16:01:24 +02:00
odwetaandCopilot App 21c23061a4 Unify manual and ZIP invoice PDF styling
The create-screen "print" action used a separate browser-rendered HTML
template while ZIP exports used a PDFKit-rendered PDF, so the two looked
different. Extract the ZIP export's PDF rendering into a shared
renderInvoicePdf function, add a POST /api/invoices/pdf endpoint that
renders the current editor form data through it, and have the print
button download that PDF instead of opening a print window with the old
HTML template.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 16:01:08 +02:00
odweta 84657b9610 Merge pull request #1 from odweta/odweta-invoice-due-dates
Remove custom invoice due date option
2026-09-10 15:59:39 +02:00
odwetaandCopilot App 2bcf9a2f02 Remove custom invoice due date option
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 15:58:43 +02:00
5 changed files with 176 additions and 170 deletions
+85
View File
@@ -0,0 +1,85 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: app/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build-and-push:
name: Build and push image (dev)
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=dev
type=sha,prefix=dev-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Summary
run: |
echo "### Dev image published :rocket:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
+28
View File
@@ -20,3 +20,31 @@ docker compose up --build
Then open `http://localhost:3001` and create an account. PostgreSQL data is kept
in the `postgres-data` Docker volume, so it survives app and container restarts.
To remove the database as well, run `docker compose down -v`.
## CI/CD
On every push to `main`, GitHub Actions (`.github/workflows/ci-cd.yml`) runs the
test suite, then builds and publishes a dev image to the GitHub Container
Registry:
```
ghcr.io/odweta/fakturovac:dev
```
A commit-pinned tag (`ghcr.io/odweta/fakturovac:dev-<short-sha>`) is published
alongside `:dev` for traceability. Pull requests only run the test job.
To pull and run the latest dev image in a testing environment:
```bash
docker login ghcr.io -u <your-github-username> # only needed if the package is private
docker pull ghcr.io/odweta/fakturovac:dev
docker run -d -p 3001:3001 \
-e DATABASE_URL=postgres://fakturovac:fakturovac@<db-host>:5432/fakturovac \
-e NODE_ENV=production \
ghcr.io/odweta/fakturovac:dev
```
The package's visibility (public/private) is managed under the repository's
**Packages** settings on GitHub; no extra secrets are required since the
workflow authenticates with the built-in `GITHUB_TOKEN`.
+37 -11
View File
@@ -481,7 +481,7 @@ const buildPaymentQrPayload = (payment, total, invoiceNumber = '') => {
].filter(Boolean).join('*');
};
const appendInvoicePdf = async (archive, invoice) => {
const renderInvoicePdf = async (document, invoice) => {
const data = invoice.invoice_data || {};
const supplier = data.supplier || {};
const customer = data.customer || {};
@@ -492,7 +492,6 @@ const appendInvoicePdf = async (archive, invoice) => {
const customerName = customer.typSubjektu === 'spolecnost'
? customer.nazevSpolecnosti
: [customer.jmeno, customer.prijmeni].filter(Boolean).join(' ');
const document = new PDFDocument({ size: 'A4', margin: 50 });
const regularFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
const boldFont = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
const qrPayload = buildPaymentQrPayload(payment, data.total, invoice.invoice_number);
@@ -503,12 +502,10 @@ const appendInvoicePdf = async (archive, invoice) => {
}) : null;
const pageWidth = document.page.width - document.page.margins.left - document.page.margins.right;
const navy = '#183b56';
const blue = '#2b6f8f';
const line = '#d8d4cc';
const lightBlue = '#f4f7fb';
const warm = '#fffaf0';
const bodyText = '#17202a';
const muted = '#65717d';
const address = (profile) => [
profile.uliceCp,
[profile.psc, profile.mesto].filter(Boolean).join(', ')
@@ -521,7 +518,6 @@ const appendInvoicePdf = async (archive, invoice) => {
const partyWidth = (pageWidth - 24) / 2;
const customerX = document.page.margins.left + partyWidth + 24;
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
const headerY = document.y;
document.fillColor(navy).font(boldFont).fontSize(25).text('Faktura', document.page.margins.left, headerY);
document.fillColor(navy).font(boldFont).fontSize(16).text(invoice.invoice_number, customerX, headerY, {
@@ -576,20 +572,50 @@ const appendInvoicePdf = async (archive, 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;
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();
};
const appendInvoicePdf = async (archive, invoice) => {
const document = new PDFDocument({ size: 'A4', margin: 50 });
archive.append(document, { name: `${invoice.invoice_number}.pdf` });
await renderInvoicePdf(document, invoice);
};
app.post('/api/invoices/pdf', requireAuth, async (req, res, next) => {
try {
const data = normalizeInvoiceData(req.body.data);
if (!data) {
res.status(400).json({ error: 'Neplatná data faktury.' });
return;
}
const invoiceNumber = String(req.body.invoiceNumber || 'Nová faktura').trim() || 'Nová faktura';
const document = new PDFDocument({ size: 'A4', margin: 50 });
res.attachment(`${invoiceNumber}.pdf`);
document.pipe(res);
await renderInvoicePdf(document, { invoice_number: invoiceNumber, invoice_data: data });
} catch (error) {
next(error);
}
});
app.get('/api/invoices/export', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
-5
View File
@@ -312,13 +312,8 @@
<select id="dueDateOption">
<option value="14">14 dní</option>
<option value="30">30 dní</option>
<option value="custom">Vlastní počet dní</option>
</select>
</div>
<div class="form-group custom-due-days-group" hidden>
<label for="customDueDays">Počet dní</label>
<input id="customDueDays" type="number" min="1" step="1" value="14" />
</div>
<output id="dueDateDisplay" class="due-date-display"></output>
</div>
</div>
+25 -153
View File
@@ -41,8 +41,6 @@ const savePasswordButton = document.getElementById("savePasswordButton");
const cancelPasswordButton = document.getElementById("cancelPasswordButton");
const passwordStatus = document.getElementById("passwordStatus");
const dueDateOption = document.getElementById("dueDateOption");
const customDueDays = document.getElementById("customDueDays");
const customDueDaysGroup = document.querySelector(".custom-due-days-group");
const dueDateDisplay = document.getElementById("dueDateDisplay");
const paymentPresetSelect = document.getElementById("paymentPresetSelect");
const paymentPresetName = document.getElementById("paymentPresetName");
@@ -110,9 +108,7 @@ const getClientLabel = (client) => [
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
const getDueDays = () => dueDateOption.value === "custom"
? Math.max(1, Number.parseInt(customDueDays.value, 10) || 14)
: Number.parseInt(dueDateOption.value, 10);
const getDueDays = () => Number.parseInt(dueDateOption.value, 10) || 14;
const getDueDate = () => {
const dueDate = new Date();
@@ -122,7 +118,6 @@ const getDueDate = () => {
};
const updateDueDate = () => {
customDueDaysGroup.hidden = dueDateOption.value !== "custom";
dueDateDisplay.textContent = `Splatnost: ${formatCzechDate(getDueDate())}`;
};
@@ -630,8 +625,6 @@ const formatCzechNumber = (value) => Number(value || 0).toLocaleString("cs-CZ",
maximumFractionDigits: 2
});
const getTotalValue = () => Number.parseFloat(getValue("total").replace(",", ".")) || 0;
const updateTotal = () => {
const total = Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.reduce((sum, item) => {
@@ -643,153 +636,32 @@ const updateTotal = () => {
document.getElementById("total").textContent = `${formatCzechNumber(total)}`;
};
const getPersonName = (prefix) => [
getValue(`${prefix}Jmeno`),
getValue(`${prefix}Prijmeni`)
].filter(Boolean).join(" ");
printInvoiceButton.addEventListener("click", async () => {
try {
const response = await fetch("/api/invoices/pdf", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: readInvoiceData(),
invoiceNumber: invoiceNumberDisplay.textContent
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Generování faktury se nepodařilo.");
}
const getAddress = (prefix) => [
getValue(`${prefix}UliceCp`),
[getValue(`${prefix}Psc`), getValue(`${prefix}Mesto`)].filter(Boolean).join(" ")
].filter(Boolean).join(", ");
const renderPerson = (prefix) => [
((prefix === "odberatel" && getValue("odberatelTypSubjektu") === "spolecnost")
|| (prefix === "dodavatel" && getValue("dodavatelTypSubjektu") === "spolecnost"))
? getValue(`${prefix}NazevSpolecnosti`)
: getPersonName(prefix),
getValue(`${prefix}Ico`) ? `IČO: ${getValue(`${prefix}Ico`)}` : "",
getAddress(prefix)
].filter(Boolean).map(escapeHtml).join("<br>");
const renderItems = () => Array.from(itemsContainer.querySelectorAll(".invoice-item"))
.map((item) => {
const values = Array.from(item.querySelectorAll("input")).map((input) => input.value.trim());
return `<tr class="invoice-row">
<td>${escapeHtml(values[0])}</td>
<td>${escapeHtml(values[1] ? formatCzechNumber(values[1]) : "0,00")}</td>
<td>${escapeHtml(values[2])}</td>
<td>${escapeHtml(values[3] ? formatCzechNumber(values[3]) : "0,00")} Kč</td>
</tr>`;
}).join("");
const normalizeIban = (value) => value.replace(/\s+/g, "").toUpperCase();
const domesticAccountToIban = (accountNumber) => {
const match = accountNumber.replace(/\s+/g, "")
.match(/^(?:(\d{1,6})-)?(\d{1,10})\/(\d{4})$/);
if (!match) {
return "";
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = `${invoiceNumberDisplay.textContent}.pdf`;
link.click();
URL.revokeObjectURL(downloadUrl);
} catch (error) {
window.alert(error.message);
}
const bban = `${match[3]}${(match[1] || "").padStart(6, "0")}${match[2].padStart(10, "0")}`;
const remainder = `${bban}123500`.split("").reduce(
(value, digit) => (value * 10 + Number(digit)) % 97,
0
);
const checkDigits = String(98 - remainder).padStart(2, "0");
return `CZ${checkDigits}${bban}`;
};
const buildPaymentQrPayload = (accountNumber, iban, swift, total, invoiceNumber = "") => {
const preferredAccount = normalizeIban(accountNumber);
const fallbackAccount = normalizeIban(iban);
const paymentAccount = /^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(preferredAccount)
? preferredAccount
: domesticAccountToIban(accountNumber) || fallbackAccount;
if (!/^([A-Z]{2})\d{2}[A-Z0-9]{10,32}$/.test(paymentAccount)) {
return "";
}
return [
"SPD*1.0",
`ACC:${paymentAccount}`,
`AM:${Number(total || 0).toFixed(2)}`,
"CC:CZK",
invoiceNumber && `X-VS:${invoiceNumber.replace(/\D/g, "").slice(-10)}`,
swift && `X-SWIFT:${normalizeIban(swift)}`
].filter(Boolean).join("*");
};
const buildInvoiceHtml = () => {
const accountNumber = getValue("cisloUctu");
const iban = getValue("iban");
const swift = getValue("swift");
const qrPayload = buildPaymentQrPayload(
accountNumber,
iban,
swift,
Number.parseFloat(getValue("total").replace(",", ".")) || 0,
invoiceNumberDisplay.textContent
);
const qrMarkup = qrPayload
? `<img class="qr-code" src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&amp;data=${encodeURIComponent(qrPayload)}" alt="QR kód platebních údajů">`
: "<p>Platební QR kód vyžaduje platný IBAN. Číslo účtu bez IBANu nelze bezpečně převést.</p>";
const paymentDetails = [
accountNumber && `Číslo účtu: ${accountNumber}`,
iban && `IBAN: ${iban}`,
swift && `SWIFT: ${swift}`
].filter(Boolean).map(escapeHtml).join("<br>");
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Faktura</title>
<style>
@page { size: A4; margin: 18mm; }
* { box-sizing: border-box; }
body { color: #202124; font: 14px Arial, sans-serif; margin: 0; }
h1 { font-size: 30px; margin: 0 0 28px; }
h2 { font-size: 16px; margin: 0 0 8px; }
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 28px; }
.party, .payment { border-top: 2px solid #202124; padding-top: 10px; }
table { border-collapse: separate; border-spacing: 0; margin-top: 24px; width: 100%; }
th, td { padding: 9px 6px; text-align: left; }
th { background: #f1f1f1; border-bottom: 1px solid #aeb8c0; }
.invoice-row td { border-bottom: 1px solid #c8c8c8; }
.invoice-row:nth-child(even) { background: #f4f7fb; }
.total { font-size: 18px; font-weight: bold; margin-top: 22px; text-align: right; }
.payment-row { align-items: start; display: flex; gap: 28px; justify-content: space-between; margin-top: 36px; }
.qr-code { height: 180px; width: 180px; }
.qr-placeholder { color: #666; max-width: 220px; }
@media print { .qr-placeholder { color: #202124; } }
</style>
</head>
<body>
<h1>Faktura ${escapeHtml(invoiceNumberDisplay.textContent)}</h1>
<div class="parties">
<section class="party"><h2>Dodavatel</h2>${renderPerson("dodavatel") || "Neuvedeno"}</section>
<section class="party"><h2>Odběratel</h2>${renderPerson("odberatel") || "Neuvedeno"}</section>
</div>
<table>
<thead><tr><th>Položka</th><th>Množství</th><th>MJ</th><th>Cena za MJ</th></tr></thead>
<tbody>${renderItems() || "<tr><td colspan=\"4\">Žádné položky</td></tr>"}</tbody>
</table>
<p class="total">Celková částka: ${formatCzechNumber(getTotalValue())} Kč</p>
<div class="payment-row">
<section class="payment"><h2>Platební údaje</h2>${paymentDetails || "Neuvedeno"}</section>
<div class="qr-placeholder">${qrMarkup}</div>
</div>
</body>
</html>`;
};
printInvoiceButton.addEventListener("click", () => {
const printWindow = window.open("about:blank", "_blank");
if (!printWindow) {
window.alert("Povolte prosím vyskakovací okna pro tisk faktury.");
return;
}
printWindow.document.open();
printWindow.document.write(buildInvoiceHtml());
printWindow.document.close();
printWindow.addEventListener("load", () => printWindow.print(), { once: true });
});
const addInvoiceItem = (values = {}) => {