- 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>
1114 lines
34 KiB
PHP
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(
|
|
'/(<[^>]*>)|( )/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>
|
|
' . $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> ' . $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);
|
|
}
|
|
}
|