Files
app/api/admin/invoices-pdf.php
Simon 758be819c3 feat: P4 backend kvalita - SELECT * fix, overdue konsolidace, Validator
- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu)
- users-handlers.php: password_hash explicitne vyloucen z dotazu
- Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech)
- Validator.php: validacni helper s pravidly required, string, int, email, in, numeric
- PaginationHelper: PHPStan typy opraveny

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:42:42 +01:00

1114 lines
34 KiB
PHP

<?php
/**
* Invoice PDF Export (Print-ready HTML)
*
* Generuje HTML fakturu dle vzoru POHODA s QR platebnim kodem (SPAYD).
* GET /api/admin/invoices-pdf.php?id=X
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/CnbRates.php';
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
setCorsHeaders();
setSecurityHeaders();
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
header('Content-Type: application/json; charset=utf-8');
errorResponse('Metoda není povolena', 405);
}
$authData = JWTAuth::requireAuth();
requirePermission($authData, 'invoices.export');
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if (!$id) {
header('Content-Type: application/json; charset=utf-8');
errorResponse('ID faktury je povinné');
}
$lang = in_array($_GET['lang'] ?? '', ['cs', 'en']) ? $_GET['lang'] : 'cs';
try {
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, invoice_number, order_id, customer_id, status, currency,
vat_rate, apply_vat, payment_method, constant_symbol,
bank_name, bank_swift, bank_iban, bank_account,
issue_date, due_date, tax_date, paid_date,
issued_by, notes
FROM invoices WHERE id = ?'
);
$stmt->execute([$id]);
$invoice = $stmt->fetch();
if (!$invoice) {
header('Content-Type: application/json; charset=utf-8');
errorResponse('Faktura nebyla nalezena', 404);
}
// Polozky
$stmt = $pdo->prepare(
'SELECT id, invoice_id, description, quantity, unit, unit_price, vat_rate, position
FROM invoice_items WHERE invoice_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$items = $stmt->fetchAll();
// Zakaznik
$customer = null;
if ($invoice['customer_id']) {
$stmt = $pdo->prepare(
'SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers WHERE id = ?'
);
$stmt->execute([$invoice['customer_id']]);
$customer = $stmt->fetch();
}
// Firemni udaje
$stmt = $pdo->query(
'SELECT id, company_name, company_id, vat_id, street, city,
postal_code, country, custom_fields, logo_data,
default_currency, default_vat_rate
FROM company_settings LIMIT 1'
);
$settings = $stmt->fetch();
// Logo
$logoBase64 = '';
$logoMime = 'image/png';
if (!empty($settings['logo_data'])) {
$logoBase64 = base64_encode($settings['logo_data']);
$finfo = new finfo(FILEINFO_MIME_TYPE);
$detected = $finfo->buffer($settings['logo_data']);
$allowedMimes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if ($detected && in_array($detected, $allowedMimes)) {
$logoMime = $detected;
}
}
// Helpery
$esc = function ($str) {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
};
$formatNum = function ($number, $decimals = 2) {
return number_format((float)$number, $decimals, ',', "\xC2\xA0");
};
$formatDate = function ($dateStr) {
if (!$dateStr) {
return '';
}
$d = strtotime($dateStr);
return $d !== false ? date('d.m.Y', $d) : $dateStr;
};
// Preklady statickych textu
$translations = [
'cs' => [
'title' => 'Faktura',
'heading' => 'FAKTURA - DAŇOVÝ DOKLAD č.',
'supplier' => 'Dodavatel',
'customer' => 'Odběratel',
'bank' => 'Banka:',
'swift' => 'SWIFT:',
'iban' => 'IBAN:',
'account_no' => 'Číslo účtu:',
'var_symbol' => 'Variabilní s.:',
'const_symbol' => 'Konstantní s.:',
'order_no' => 'Objednávka č.:',
'issue_date' => 'Datum vystavení:',
'due_date' => 'Datum splatnosti:',
'tax_date' => 'Datum uskutečnění plnění:',
'payment_method' => 'Forma úhrady:',
'billing' => 'Fakturujeme Vám za:',
'col_no' => 'Č.',
'col_desc' => 'Popis',
'col_qty' => 'Množství',
'col_unit_price' => 'Jedn. cena',
'col_price' => 'Cena',
'col_vat_pct' => '%DPH',
'col_vat' => 'DPH',
'col_total' => 'Celkem',
'subtotal' => 'Mezisoučet:',
'vat_label' => 'DPH',
'total' => 'Celkem k úhradě',
'amounts_in' => 'Částky jsou uvedeny v',
'notes' => 'Poznámky',
'issued_by' => 'Vystavil:',
'notice' => 'Dovolujeme si Vás upozornit, že v případě nedodržení data splatnosti'
. ' uvedeného na faktuře Vám budeme účtovat úrok z prodlení v dohodnuté, resp.'
. ' zákonné výši a smluvní pokutu (byla-li sjednána).',
'vat_recap' => 'Rekapitulace DPH v Kč:',
'vat_base' => 'Základ v Kč',
'vat_rate' => 'Sazba',
'vat_amount' => 'DPH v Kč',
'vat_with_total' => 'Celkem s DPH v Kč',
'received_by' => 'Převzal:',
'stamp' => 'Razítko:',
'ico' => 'IČ: ',
'dic' => 'DIČ: ',
],
'en' => [
'title' => 'Invoice',
'heading' => 'INVOICE - TAX DOCUMENT No.',
'supplier' => 'Supplier',
'customer' => 'Customer',
'bank' => 'Bank:',
'swift' => 'SWIFT:',
'iban' => 'IBAN:',
'account_no' => 'Account No.:',
'var_symbol' => 'Variable symbol:',
'const_symbol' => 'Constant symbol:',
'order_no' => 'Order No.:',
'issue_date' => 'Issue date:',
'due_date' => 'Due date:',
'tax_date' => 'Tax point date:',
'payment_method' => 'Payment method:',
'billing' => 'We invoice you for:',
'col_no' => 'No.',
'col_desc' => 'Description',
'col_qty' => 'Quantity',
'col_unit_price' => 'Unit price',
'col_price' => 'Price',
'col_vat_pct' => 'VAT%',
'col_vat' => 'VAT',
'col_total' => 'Total',
'subtotal' => 'Subtotal:',
'vat_label' => 'VAT',
'total' => 'Total to pay',
'amounts_in' => 'Amounts are in',
'notes' => 'Notes',
'issued_by' => 'Issued by:',
'notice' => 'Please note that in case of late payment, we will charge default interest'
. ' at the agreed or statutory rate and a contractual penalty (if agreed).',
'vat_recap' => 'VAT recapitulation in CZK:',
'vat_base' => 'Tax base in CZK',
'vat_rate' => 'Rate',
'vat_amount' => 'VAT in CZK',
'vat_with_total' => 'Total incl. VAT in CZK',
'received_by' => 'Received by:',
'stamp' => 'Stamp:',
'ico' => 'Reg. No.: ',
'dic' => 'Tax ID: ',
],
];
$t = $translations[$lang];
$currency = $invoice['currency'] ?? 'CZK';
$applyVat = !empty($invoice['apply_vat']);
// Vypocty
$vatSummary = [];
$subtotal = 0;
foreach ($items as $item) {
$lineSubtotal = (float)$item['quantity'] * (float)$item['unit_price'];
$subtotal += $lineSubtotal;
$rate = (float)$item['vat_rate'];
if (!isset($vatSummary[(string)$rate])) {
$vatSummary[(string)$rate] = ['base' => 0, 'vat' => 0];
}
$vatSummary[(string)$rate]['base'] += $lineSubtotal;
if ($applyVat) {
$vatSummary[(string)$rate]['vat'] += $lineSubtotal * $rate / 100;
}
}
$totalVat = 0;
foreach ($vatSummary as $data) {
$totalVat += $data['vat'];
}
$totalToPay = $subtotal + $totalVat;
// Rekapitulace DPH - vzdy v CZK (cesky danovy doklad)
$isForeign = strtoupper($currency) !== 'CZK';
$cnbRate = 1.0;
if ($isForeign) {
$cnb = CnbRates::getInstance();
$issueDate = $invoice['issue_date'] ?? '';
// Kurz CNB k datu vystaveni
$cnbRate = $cnb->toCzk(1.0, $currency, $issueDate);
}
$vatRates = [21, 12, 0];
$vatRecap = [];
foreach ($vatRates as $rate) {
$key = (string)(float)$rate;
$base = $vatSummary[$key]['base'] ?? 0;
$vat = $vatSummary[$key]['vat'] ?? 0;
$vatRecap[] = [
'rate' => $rate,
'base' => round($base * $cnbRate, 2),
'vat' => round($vat * $cnbRate, 2),
'total' => round(($base + $vat) * $cnbRate, 2),
];
}
// QR kod - SPAYD
$spaydParts = [
'SPD*1.0',
'ACC:' . str_replace(' ', '', $invoice['bank_iban']),
'AM:' . number_format($totalToPay, 2, '.', ''),
'CC:' . $currency,
'X-VS:' . $invoice['invoice_number'],
'X-KS:' . ($invoice['constant_symbol'] ?: '0308'),
'MSG:' . $t['title'] . ' ' . $invoice['invoice_number'],
];
$spaydString = implode('*', $spaydParts);
$qrSvg = '';
try {
$qrOptions = new QROptions([
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
'eccLevel' => QRCode::ECC_M,
'scale' => 3,
'addQuietzone' => true,
'svgUseCssProperties' => false,
]);
$qrCode = new QRCode($qrOptions);
$qrSvg = $qrCode->render($spaydString);
} catch (Exception $e) {
error_log('QR code generation failed: ' . $e->getMessage());
}
// Sanitizace HTML z Rich editoru
$cleanQuillHtml = function (string $html): string {
$allowedTags = '<p><br><strong><em><u><s><ul><ol><li>'
. '<span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>';
$html = strip_tags($html, $allowedTags);
// Odstranit event handlery (quoted i unquoted hodnoty)
$html = preg_replace('/\s+on\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
$html = preg_replace('/\s+on\w+\s*=\s*[^\s>]*/i', '', $html);
// Odstranit javascript: v href a jinych atributech
$html = preg_replace('/href\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'href="#"', $html);
$html = preg_replace('/\s+javascript\s*:/i', '', $html);
$html = preg_replace_callback(
'/(<[^>]*>)|(&nbsp;)/u',
function ($m) {
if (!empty($m[1])) {
return $m[1];
}
return ' ';
},
$html
);
$prev = null;
while ($prev !== $html) {
$prev = $html;
$html = preg_replace_callback(
'/<span([^>]*)>(.*?)<\/span>\s*<span(\1)>/su',
fn ($m) => '<span' . $m[1] . '>' . $m[2],
$html
);
}
return $html;
};
$indentCSS = '';
for ($n = 1; $n <= 9; $n++) {
$pad = $n * 3;
$liPad = $n * 3 + 1.5;
$indentCSS .= " .ql-indent-{$n} { padding-left: {$pad}em; }\n";
$indentCSS .= " li.ql-indent-{$n} { padding-left: {$liPad}em; }\n";
}
// Logo img
$logoImg = '';
if ($logoBase64) {
$logoImg = '<img src="data:' . $esc($logoMime) . ';base64,' . $logoBase64 . '" class="logo" />';
}
// Polozky HTML
$itemsHtml = '';
foreach ($items as $i => $item) {
$lineSubtotal = (float)$item['quantity'] * (float)$item['unit_price'];
$vatRate = (float)$item['vat_rate'];
$lineVat = $applyVat ? $lineSubtotal * $vatRate / 100 : 0;
$lineTotal = $lineSubtotal + $lineVat;
$rowNum = $i + 1;
$itemsHtml .= '<tr>
<td class="row-num">' . $rowNum . '</td>
<td class="desc">' . $esc($item['description']) . '</td>
<td class="center">' . $formatNum(
$item['quantity'],
(floor((float)$item['quantity']) == (float)$item['quantity'] ? 0 : 2)
) . '</td>
<td class="right">' . $formatNum($item['unit_price']) . '</td>
<td class="right">' . $formatNum($lineSubtotal) . '</td>
<td class="center">' . ($applyVat ? intval($vatRate) : 0) . '%</td>
<td class="right">' . $formatNum($lineVat) . '</td>
<td class="right total-cell">' . $formatNum($lineTotal) . '</td>
</tr>';
}
// DPH rekapitulace HTML
$vatRecapHtml = '';
foreach ($vatRecap as $vr) {
$vatRecapHtml .= '<tr>
<td class="right">' . $formatNum($vr['base']) . '</td>
<td class="center">' . intval($vr['rate']) . '%</td>
<td class="right">' . $formatNum($vr['vat']) . '</td>
<td class="right">' . $formatNum($vr['total']) . '</td>
</tr>';
}
$invoiceNumber = $esc($invoice['invoice_number']);
// Odberatel
$custName = $customer ? $esc($customer['name']) : '';
$custStreet = $customer ? $esc($customer['street'] ?? '') : '';
$custCity = $customer ? trim(($customer['postal_code'] ?? '') . ' ' . ($customer['city'] ?? '')) : '';
$custCountry = $customer ? $esc($customer['country'] ?? '') : '';
$custIco = $customer ? $esc($customer['company_id'] ?? '') : '';
$custDic = $customer ? $esc($customer['vat_id'] ?? '') : '';
// Dodavatel
$suppName = $esc($settings['company_name'] ?? '');
$suppStreet = $esc($settings['street'] ?? '');
$suppCity = trim(($settings['postal_code'] ?? '') . ' ' . ($settings['city'] ?? ''));
$suppCountry = $esc($settings['country'] ?? 'Česká republika');
$suppIco = $esc($settings['company_id'] ?? '');
$suppDic = $esc($settings['vat_id'] ?? '');
$suppEmail = $esc($settings['Email'] ?? '');
$suppWeb = $esc($settings['Website'] ?? '');
// Sestaveni adresovych radku (stejne jako v nabidkach)
$buildAddressLines = function ($entity, $isSupplier) use ($t) {
if (!$entity) {
return ['name' => '', 'lines' => []];
}
$nameKey = $isSupplier ? 'company_name' : 'name';
$name = $entity[$nameKey] ?? '';
$cfData = [];
$fieldOrder = null;
$raw = $entity['custom_fields'] ?? null;
if ($raw) {
$parsed = is_string($raw) ? json_decode($raw, true) : $raw;
if (isset($parsed['fields'])) {
$cfData = $parsed['fields'] ?? [];
$fieldOrder = $parsed['field_order'] ?? $parsed['fieldOrder'] ?? null;
} elseif (is_array($parsed) && isset($parsed[0])) {
$cfData = $parsed;
}
}
// Zpetna kompatibilita - stare DB zaznamy maji PascalCase klice
if (is_array($fieldOrder)) {
$legacyMap = [
'Name' => 'name', 'CompanyName' => 'company_name',
'Street' => 'street', 'CityPostal' => 'city_postal',
'Country' => 'country', 'CompanyId' => 'company_id', 'VatId' => 'vat_id',
];
$fieldOrder = array_map(fn ($k) => $legacyMap[$k] ?? $k, $fieldOrder);
}
$fieldMap = [];
if ($name) {
$fieldMap[$nameKey] = $name;
}
if (!empty($entity['street'])) {
$fieldMap['street'] = $entity['street'];
}
$cityParts = array_filter([$entity['city'] ?? '', $entity['postal_code'] ?? '']);
$cityPostal = trim(implode(' ', $cityParts));
if ($cityPostal) {
$fieldMap['city_postal'] = $cityPostal;
}
if (!empty($entity['country'])) {
$fieldMap['country'] = $entity['country'];
}
if (!empty($entity['company_id'])) {
$fieldMap['company_id'] = $t['ico'] . $entity['company_id'];
}
if (!empty($entity['vat_id'])) {
$fieldMap['vat_id'] = $t['dic'] . $entity['vat_id'];
}
foreach ($cfData as $i => $cf) {
$cfName = trim($cf['name'] ?? '');
$cfValue = trim($cf['value'] ?? '');
$showLabel = $cf['showLabel'] ?? true;
if ($cfValue) {
$fieldMap["custom_{$i}"] = ($showLabel && $cfName) ? "{$cfName}: {$cfValue}" : $cfValue;
}
}
$lines = [];
if (is_array($fieldOrder) && count($fieldOrder)) {
foreach ($fieldOrder as $key) {
if ($key === $nameKey) {
continue;
}
if (isset($fieldMap[$key])) {
$lines[] = $fieldMap[$key];
}
}
foreach ($fieldMap as $key => $line) {
if ($key === $nameKey) {
continue;
}
if (!in_array($key, $fieldOrder)) {
$lines[] = $line;
}
}
} else {
foreach ($fieldMap as $key => $line) {
if ($key === $nameKey) {
continue;
}
$lines[] = $line;
}
}
return ['name' => $name, 'lines' => $lines];
};
$supp = $buildAddressLines($settings, true);
$cust = $buildAddressLines($customer, false);
$suppLinesHtml = '';
foreach ($supp['lines'] as $line) {
$suppLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
}
$custLinesHtml = '';
foreach ($cust['lines'] as $line) {
$custLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
}
// Objednavka
$orderNumber = '';
$orderDate = '';
if ($invoice['order_id']) {
$stmtOrder = $pdo->prepare('SELECT order_number, customer_order_number, created_at FROM orders WHERE id = ?');
$stmtOrder->execute([$invoice['order_id']]);
$orderRow = $stmtOrder->fetch();
if ($orderRow) {
$orderNumber = $esc($orderRow['customer_order_number'] ?: $orderRow['order_number']);
$orderDate = $formatDate($orderRow['created_at'] ?? '');
}
}
$html = '<!DOCTYPE html>
<html lang="' . $esc($lang) . '">
<head>
<meta charset="utf-8" />
<title>' . $esc($t['title']) . ' ' . $invoiceNumber . '</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
<style>
@page {
size: A4;
margin: 12mm 15mm 15mm 15mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 9pt;
color: #1a1a1a;
width: 180mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
page-break-inside: avoid;
break-inside: avoid;
}
.accent { color: #de3a3a; }
/* Hlavicka */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
padding-bottom: 1mm;
}
.invoice-header .left {
display: flex;
align-items: flex-start;
gap: 3mm;
}
.logo-header {
text-align: left;
}
.company-title {
font-size: 12pt;
font-weight: 700;
margin-top: 2mm;
}
.invoice-title {
font-size: 10pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
margin-top: 2mm;
}
/* Adresy - dva sloupce, stejna vyska */
.addresses-row {
display: flex;
gap: 8mm;
align-items: stretch;
margin-bottom: 0;
}
.addresses-row .address-block {
flex: 1;
padding-bottom: 2mm;
border-bottom: 0.5pt solid #e0e0e0;
}
/* Detaily pod adresami */
.details-row {
display: flex;
gap: 8mm;
margin-bottom: 3mm;
}
.details-row .col { flex: 1; }
/* Adresy - styl z nabidek */
.address-block {
margin-bottom: 0;
}
.address-label {
font-size: 8pt;
font-weight: 600;
color: #646464;
line-height: 1.5;
}
.address-name {
font-size: 9pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.5;
}
.address-line {
font-size: 8.5pt;
color: #1a1a1a;
line-height: 1.5;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* Separator */
.header-separator {
border: none;
border-top: 0.5pt solid #e0e0e0;
margin: 2mm 0 3mm 0;
}
/* Banka */
.bank-box {
font-size: 8pt;
line-height: 1.4;
padding-top: 2mm;
}
.bank-box .lbl {
font-weight: 600;
color: #1a1a1a;
display: inline-block;
min-width: 16mm;
}
/* Datumy */
.dates-box {
font-size: 8pt;
line-height: 1.4;
padding-top: 2mm;
}
.dates-row {
display: flex;
align-items: center;
margin-bottom: 0.5mm;
}
.dates-row .lbl {
flex: 1;
color: #1a1a1a;
}
.dates-row .val {
font-weight: 600;
min-width: 22mm;
text-align: center;
padding: 0.5mm 2mm;
}
/* VS/KS blok */
.vs-block {
font-size: 8pt;
line-height: 1.4;
padding-top: 2mm;
}
/* Konecny prijemce */
.recipient-box {
font-size: 8pt;
margin-top: 2mm;
padding-top: 2mm;
border-top: 0.5pt solid #e0e0e0;
}
.recipient-box .lbl {
font-weight: 600;
font-style: italic;
color: #646464;
}
/* Polozky tabulka - styl z nabidek */
.billing-label {
font-weight: 600;
color: #de3a3a;
font-size: 8.5pt;
padding: 3px 5px;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8pt;
font-weight: 600;
color: #646464;
padding: 6px 8px;
text-align: left;
letter-spacing: 0.02em;
text-transform: uppercase;
border-bottom: 1pt solid #1a1a1a;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 5px 8px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 8pt;
}
table.items tbody td.desc {
font-size: 9.5pt;
font-weight: 500;
color: #1a1a1a;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 8.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 9.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 7.5pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 8pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 7pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* DPH rekapitulace + QR */
.recap-section {
display: flex;
gap: 5mm;
align-items: flex-start;
margin-top: 1mm;
}
.recap-section .qr {
flex-shrink: 0;
width: 28mm;
}
.recap-section .qr img,
.recap-section .qr svg { width: 28mm; height: 28mm; }
.recap-section table {
border-collapse: collapse;
font-size: 8pt;
flex: 1;
}
.recap-section table th {
font-size: 7.5pt;
font-weight: 600;
color: #555;
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #ccc;
}
.recap-section table td {
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #eee;
}
.recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate {
font-size: 7pt;
color: #888;
text-align: right;
border-bottom: none;
padding-top: 4px;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
margin-top: 4mm;
font-size: 8pt;
}
.footer-row .col {
flex: 1;
border-top: 0.5pt solid #aaa;
padding-top: 2mm;
font-weight: 600;
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 9pt;
line-height: 1.5;
color: #1a1a1a;
}
.invoice-notes-label {
font-weight: 600;
font-size: 8pt;
text-transform: uppercase;
color: #555;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
/* Quill fonty */
.ql-font-arial { font-family: Arial, sans-serif; }
.ql-font-tahoma { font-family: Tahoma, sans-serif; }
.ql-font-verdana { font-family: Verdana, sans-serif; }
.ql-font-georgia { font-family: Georgia, serif; }
.ql-font-times-new-roman { font-family: "Times New Roman", serif; }
.ql-font-courier-new { font-family: "Courier New", monospace; }
.ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; }
.ql-font-impact { font-family: Impact, sans-serif; }
.ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; }
.ql-font-lucida-console { font-family: "Lucida Console", monospace; }
.ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; }
.ql-font-garamond { font-family: Garamond, serif; }
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
' . $indentCSS . '
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
' . ($logoImg ? '<div class="logo-header">' . $logoImg . '</div>' : '') . '
</div>
<div class="invoice-title">' . $esc($t['heading']) . ' ' . $invoiceNumber . '</div>
</div>
<hr class="header-separator" />
<!-- Dodavatel / Odberatel -->
<!-- Dodavatel / Odberatel - stejna vyska -->
<div class="addresses-row">
<div class="address-block">
<div class="address-label">' . $esc($t['supplier']) . '</div>
<div class="address-name">' . $esc($supp['name']) . '</div>
' . $suppLinesHtml . '
</div>
<div class="address-block">
<div class="address-label">' . $esc($t['customer']) . '</div>
<div class="address-name">' . $esc($cust['name']) . '</div>
' . $custLinesHtml . '
</div>
</div>
<!-- Banka + VS / Datumy -->
<div class="details-row">
<div class="col">
<div class="bank-box">
<span class="lbl">' . $esc($t['bank']) . '</span> ' . $esc($invoice['bank_name']) . '<br>
<span class="lbl">' . $esc($t['swift']) . '</span> ' . $esc($invoice['bank_swift']) . '<br>
<span class="lbl">' . $esc($t['iban']) . '</span> ' . $esc($invoice['bank_iban']) . '<br>
<span class="lbl">' . $esc($t['account_no']) . '</span> ' . $esc($invoice['bank_account']) . '
</div>
<div class="vs-block">
' . $esc($t['var_symbol']) . ' <strong>' . $invoiceNumber . '</strong>
&nbsp;&nbsp;&nbsp; ' . $esc($t['const_symbol'])
. ' <strong>' . $esc($invoice['constant_symbol']) . '</strong><br>
' . ($orderNumber ? $esc($t['order_no']) . ' ' . $orderNumber : '') . '
</div>
</div>
<div class="col">
<div class="dates-box">
<div class="dates-row"><span class="lbl">' . $esc($t['issue_date']) . '</span> <span class="val">'
. $esc($formatDate($invoice['issue_date'])) . '</span></div>
<div class="dates-row"><span class="lbl">' . $esc($t['due_date']) . '</span> <span class="val">'
. $esc($formatDate($invoice['due_date'])) . '</span></div>
<div class="dates-row"><span class="lbl">' . $esc($t['tax_date']) . '</span> <span class="val">'
. $esc($formatDate($invoice['tax_date'])) . '</span></div>
<div class="dates-row"><span class="lbl">' . $esc($t['payment_method']) . '</span> <span class="val">'
. $esc($invoice['payment_method']) . '</span></div>
</div>
</div>
</div>
<!-- Polozky -->
<div class="billing-label">' . $esc($t['billing']) . '</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:5%">' . $esc($t['col_no']) . '</th>
<th style="width:30%">' . $esc($t['col_desc']) . '</th>
<th class="center" style="width:9%">' . $esc($t['col_qty']) . '</th>
<th class="right" style="width:11%">' . $esc($t['col_unit_price']) . '</th>
<th class="right" style="width:11%">' . $esc($t['col_price']) . '</th>
<th class="center" style="width:7%">' . $esc($t['col_vat_pct']) . '</th>
<th class="right" style="width:11%">' . $esc($t['col_vat']) . '</th>
<th class="right" style="width:16%">' . $esc($t['col_total']) . '</th>
</tr>
</thead>
<tbody>
' . $itemsHtml . '
</tbody>
</table>
<!-- Soucty -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">' . $esc($t['subtotal']) . '</span>
<span class="value">' . $formatNum($subtotal) . ' ' . $esc($currency) . '</span>
</div>';
// DPH radky
if ($applyVat) {
foreach ($vatSummary as $rate => $data) {
if ($data['vat'] > 0) {
$html .= '
<div class="row">
<span class="label">' . $esc($t['vat_label']) . ' ' . intval((float)$rate) . '%:</span>
<span class="value">' . $formatNum($data['vat']) . ' ' . $esc($currency) . '</span>
</div>';
}
}
}
$html .= '
</div>
<div class="grand">
<span class="label">' . $esc($t['total']) . '</span>
<span class="value">' . $formatNum($totalToPay) . ' ' . $esc($currency) . '</span>
</div>
<div class="currency-note">' . $esc($t['amounts_in']) . ' ' . $esc($currency) . '</div>
</div>
</div>
' . (!empty(trim(strip_tags($invoice['notes'] ?? ''))) ? '
<!-- Poznamky -->
<div class="invoice-notes">
<div class="invoice-notes-label">' . $esc($t['notes']) . '</div>
<div class="invoice-notes-content">' . $cleanQuillHtml($invoice['notes']) . '</div>
</div>
' : '') . '
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">' . $esc($t['issued_by']) . '</span> ' . $esc($invoice['issued_by'] ?: '') . '
' . ($suppEmail
? '<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;' . $suppEmail
: '') . '
</div>
<!-- Upozorneni -->
<div class="notice">
' . $esc($t['notice']) . '
</div>
<!-- DPH rekapitulace + QR -->
<div class="recap-section">
<div class="qr">
' . ($qrSvg ? '<img src="' . $qrSvg . '" alt="QR platba" />' : '') . '
</div>
<table>
<thead>
<tr>
<th colspan="4">' . $esc($t['vat_recap']) . '</th>
</tr>
<tr>
<th>' . $esc($t['vat_base']) . '</th>
<th>' . $esc($t['vat_rate']) . '</th>
<th>' . $esc($t['vat_amount']) . '</th>
<th>' . $esc($t['vat_with_total']) . '</th>
</tr>
</thead>
<tbody>
' . $vatRecapHtml . '
</tbody>
' . ($isForeign ? '<tfoot>
<tr>
<td colspan="4" class="cnb-rate">'
. ($lang === 'cs'
? 'Přepočet kurzem ČNB ke dni ' . $formatDate($invoice['issue_date'] ?? '')
. ': 1 ' . $esc($currency) . ' = ' . number_format($cnbRate, 3, ',', ' ') . ' CZK'
: 'CNB exchange rate as of ' . $formatDate($invoice['issue_date'] ?? '')
. ': 1 ' . $esc($currency) . ' = ' . number_format($cnbRate, 3, '.', ',') . ' CZK')
. '</td>
</tr>
</tfoot>' : '') . '
</table>
</div>
<!-- Prevzal / razitko -->
<div class="footer-row">
<div class="col">' . $esc($t['received_by']) . '</div>
<div class="col" style="text-align:right">' . $esc($t['stamp']) . '</div>
</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</body>
</html>';
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit();
} catch (PDOException $e) {
error_log('Invoices PDF API error: ' . $e->getMessage());
header('Content-Type: application/json; charset=utf-8');
if (DEBUG_MODE) {
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba generování PDF', 500);
}
} catch (Exception $e) {
error_log('Invoices PDF generation error: ' . $e->getMessage());
header('Content-Type: application/json; charset=utf-8');
if (DEBUG_MODE) {
errorResponse('Chyba PDF: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba generování PDF', 500);
}
}