Files
app/api/admin/offers-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

880 lines
28 KiB
PHP

<?php
/**
* Offers PDF Export (Print-ready HTML)
*
* Returns a self-contained HTML page that auto-triggers window.print().
* The browser's "Save as PDF" produces the final PDF.
*
* GET /api/admin/offers-pdf.php?id=X - Returns print-ready HTML
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
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, 'offers.export');
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
if (!$id) {
header('Content-Type: application/json; charset=utf-8');
errorResponse('ID nabídky je povinné');
}
try {
$pdo = db();
$stmt = $pdo->prepare(
'SELECT id, quotation_number, project_code, customer_id, created_at,
valid_until, currency, language, vat_rate, apply_vat,
exchange_rate, scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$id]);
$quotation = $stmt->fetch();
if (!$quotation) {
header('Content-Type: application/json; charset=utf-8');
errorResponse('Nabídka nebyla nalezena', 404);
}
$customer = null;
if ($quotation['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([$quotation['customer_id']]);
$customer = $stmt->fetch();
}
$stmt = $pdo->prepare(
'SELECT id, quotation_id, position, description, item_description,
quantity, unit, unit_price, is_included_in_total
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$items = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT id, quotation_id, position, title, title_cz, content
FROM scope_sections WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]);
$sections = $stmt->fetchAll();
$stmt = $pdo->query(
'SELECT id, company_name, company_id, vat_id, street, city,
postal_code, country, custom_fields, logo_data,
quotation_prefix, default_currency, default_vat_rate
FROM company_settings LIMIT 1'
);
$settings = $stmt->fetch();
$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;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
$isCzech = ($quotation['language'] ?? 'EN') === 'CZ';
$currency = $quotation['currency'] ?? 'EUR';
$translations = [
'title' => ['EN' => 'PRICE QUOTATION', 'CZ' => 'CENOVÁ NABÍDKA'],
'scope_title' => ['EN' => 'SCOPE OF THE PROJECT', 'CZ' => 'ROZSAH PROJEKTU'],
'valid_until' => ['EN' => 'Valid until', 'CZ' => 'Platnost do'],
'customer' => ['EN' => 'Customer', 'CZ' => 'Zákazník'],
'supplier' => ['EN' => 'Supplier', 'CZ' => 'Dodavatel'],
'no' => ['EN' => 'N.', 'CZ' => 'Č.'],
'description' => ['EN' => 'Description', 'CZ' => 'Popis'],
'qty' => ['EN' => 'Qty', 'CZ' => 'Mn.'],
'unit_price' => ['EN' => 'Unit Price', 'CZ' => 'Jedn. cena'],
'included' => ['EN' => 'Included', 'CZ' => 'Zahrnuto'],
'total' => ['EN' => 'Total', 'CZ' => 'Celkem'],
'subtotal' => ['EN' => 'Subtotal', 'CZ' => 'Mezisoučet'],
'vat' => ['EN' => 'VAT', 'CZ' => 'DPH'],
'total_to_pay' => ['EN' => 'Total to pay', 'CZ' => 'Celkem k úhradě'],
'exchange_rate' => ['EN' => 'Exchange rate', 'CZ' => 'Směnný kurz'],
'ico' => ['EN' => 'ID', 'CZ' => 'IČO'],
'dic' => ['EN' => 'VAT ID', 'CZ' => 'DIČ'],
'yes' => ['EN' => 'Yes', 'CZ' => 'Ano'],
'no_val' => ['EN' => 'No', 'CZ' => 'Ne'],
'page' => ['EN' => 'Page', 'CZ' => 'Strana'],
'of' => ['EN' => 'of', 'CZ' => 'z'],
];
$lang = $isCzech ? 'CZ' : 'EN';
$t = function ($key) use ($translations, $lang) {
return $translations[$key][$lang] ?? $key;
};
$formatNum = function ($number, $decimals, $decSep = ',', $thousandSep = "\xC2\xA0") {
$fixed = number_format(abs($number), $decimals, '.', '');
$parts = explode('.', $fixed);
$intPart = preg_replace('/\B(?=(\d{3})+(?!\d))/', $thousandSep, $parts[0]);
$result = isset($parts[1]) ? $intPart . $decSep . $parts[1] : $intPart;
return $number < 0 ? '-' . $result : $result;
};
$formatCurrency = function ($amount) use ($currency, $formatNum) {
$n = floatval($amount);
switch ($currency) {
case 'EUR':
return $formatNum($n, 2, ',', "\xC2\xA0") . ' €';
case 'USD':
return '$' . $formatNum($n, 2, '.', ',');
case 'CZK':
return $formatNum($n, 2, ',', "\xC2\xA0") . ' Kč';
case 'GBP':
return '£' . $formatNum($n, 2, '.', ',');
default:
return $formatNum($n, 2, ',', "\xC2\xA0") . ' ' . $currency;
}
};
$formatDate = function ($dateStr) {
if (!$dateStr) {
return '';
}
$d = strtotime($dateStr);
if ($d === false) {
return $dateStr;
}
return date('d.m.Y', $d);
};
$esc = function ($str) {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
};
$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];
};
$logoImg = '';
if ($logoBase64) {
$logoImg = '<img src="data:' . $esc($logoMime) . ';base64,' . $logoBase64 . '" class="logo" />';
}
$subtotal = 0;
foreach ($items as $item) {
$included = $item['is_included_in_total'] ?? 1;
if ($included) {
$subtotal += (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
}
}
$applyVat = !empty($quotation['apply_vat']);
$vatRate = floatval($quotation['vat_rate'] ?? 21) ?: 21;
$vatAmount = $applyVat ? $subtotal * ($vatRate / 100) : 0;
$totalToPay = $subtotal + $vatAmount;
$exchangeRate = floatval($quotation['exchange_rate'] ?? 0);
$vatMode = $applyVat ? 'standard' : 'exempt';
$hasScopeContent = false;
foreach ($sections as $s) {
if (trim($s['content'] ?? '') || trim($s['title'] ?? '')) {
$hasScopeContent = true;
break;
}
}
$sectionTitle = function ($section) use ($isCzech) {
if ($isCzech && !empty(trim($section['title_cz'] ?? ''))) {
return $section['title_cz'];
}
return $section['title'] ?? '';
};
/**
* Merge adjacent <span> tags with identical attributes produced by Quill.
* Quill sometimes splits a single word across multiple <span> elements
* when cursor operations occur mid-word, creating spurious line-break
* opportunities in the PDF renderer.
*/
$cleanQuillHtml = function (string $html): string {
// Sanitizace - povolit jen bezpecne HTML tagy z Quill editoru
$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);
// Replace &nbsp; with regular spaces in text content (not inside tags)
$html = preg_replace_callback(
'/(<[^>]*>)|(&nbsp;)/u',
function ($m) {
if (!empty($m[1])) {
return $m[1];
}
return ' ';
},
$html
);
// Merge adjacent spans with the same attributes
$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;
};
$cust = $buildAddressLines($customer, false);
$supp = $buildAddressLines($settings, true);
$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";
}
$itemsHtml = '';
foreach ($items as $i => $item) {
$lineTotal = (floatval($item['quantity']) ?: 0) * (floatval($item['unit_price']) ?: 0);
$subDesc = $item['item_description'] ?? '';
$rowNum = $i + 1;
$evenClass = ($i % 2 === 1) ? ' class="even"' : '';
$itemsHtml .= '<tr' . $evenClass . '>
<td class="row-num">' . $rowNum . '</td>
<td class="desc">' . $esc($item['description'] ?? '')
. ($subDesc ? '<div class="item-subdesc">' . $esc($subDesc) . '</div>' : '') . '</td>
<td class="center">' . $formatNum(floatval($item['quantity']) ?: 1, 0)
. (!empty(trim($item['unit'] ?? '')) ? ' / ' . $esc(trim($item['unit'])) : '') . '</td>
<td class="right">' . $formatCurrency($item['unit_price'] ?? 0) . '</td>
<td class="right total-cell">' . $formatCurrency($lineTotal) . '</td>
</tr>';
}
$totalsHtml = '';
if ($vatMode === 'standard') {
$totalsHtml .= '<div class="detail-rows">
<div class="row">
<span class="label">' . $esc($t('subtotal')) . ':</span>
<span class="value">' . $formatCurrency($subtotal) . '</span>
</div>
<div class="row">
<span class="label">' . $esc($t('vat')) . ' (' . intval($vatRate) . '%):</span>
<span class="value">' . $formatCurrency($vatAmount) . '</span>
</div>
</div>';
}
$totalsHtml .= '<div class="grand">
<span class="label">' . $esc($t('total_to_pay')) . '</span>
<span class="value">' . $formatCurrency($totalToPay) . '</span>
</div>';
if ($exchangeRate > 0) {
$totalsHtml .= '<div class="exchange-rate">'
. $esc($t('exchange_rate')) . ': ' . $formatNum($exchangeRate, 4) . '</div>';
}
$scopeHtml = '';
if ($hasScopeContent) {
$scopeHtml .= '<div class="scope-page">';
$scopeHtml .= '<div class="page-header">
<div class="left">
<div class="page-title">' . $esc($t('scope_title')) . '</div>';
if (!empty($quotation['scope_title'])) {
$scopeHtml .= '<div class="scope-subtitle">' . $esc($quotation['scope_title']) . '</div>';
}
if (!empty($quotation['scope_description'])) {
$scopeHtml .= '<div class="scope-description">' . $esc($quotation['scope_description']) . '</div>';
}
$scopeHtml .= '</div>';
if ($logoImg) {
$scopeHtml .= '<div class="right"><div class="logo-header">' . $logoImg . '</div></div>';
}
$scopeHtml .= '</div>
<hr class="separator" />';
foreach ($sections as $section) {
$title = $sectionTitle($section);
$content = trim($section['content'] ?? '');
if (!$title && !$content) {
continue;
}
$scopeHtml .= '<div class="scope-section">';
if ($title) {
$scopeHtml .= '<div class="scope-section-title">' . $esc($title) . '</div>';
}
if ($content) {
$scopeHtml .= '<div class="section-content">' . $cleanQuillHtml($content) . '</div>';
}
$scopeHtml .= '</div>';
}
$scopeHtml .= '</div>';
}
$custLinesHtml = '';
foreach ($cust['lines'] as $line) {
$custLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
}
$suppLinesHtml = '';
foreach ($supp['lines'] as $line) {
$suppLinesHtml .= '<div class="address-line">' . $esc($line) . '</div>';
}
$pageLabel = $t('page');
$ofLabel = $t('of');
$quotationNumber = $esc($quotation['quotation_number'] ?? '');
// ---------------------------------------------------------------------------
// Build final HTML
// ---------------------------------------------------------------------------
$html = '<!DOCTYPE html>
<html lang="' . ($isCzech ? 'cs' : 'en') . '">
<head>
<meta charset="utf-8" />
<title>' . $quotationNumber . '</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
<link rel="shortcut icon" href="/favicon.ico">
<style>
/* ---- Base ---- */
@page {
size: A4;
margin: 15mm 15mm 25mm 15mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 180mm;
}
/* Prevent any element from exceeding content width */
img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes ---- */
.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; }
/* ---- Quill alignment ---- */
.ql-align-center { text-align: center; }
.ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; }
/* ---- Quill indentation ---- */
' . $indentCSS . '
/* ---- Page header ---- */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4mm;
}
.page-header .left { flex: 1; }
.page-header .right { flex-shrink: 0; margin-left: 10mm; }
.logo { max-width: 42mm; max-height: 22mm; object-fit: contain; }
.page-title {
font-size: 18pt;
font-weight: bold;
color: #1a1a1a;
margin: 0;
}
.scope-page .page-title { font-size: 16pt; }
.quotation-number {
font-size: 12pt;
color: #1a1a1a;
margin: 1mm 0;
}
.project-code {
font-size: 10pt;
color: #646464;
}
.valid-until {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.scope-subtitle {
font-size: 11pt;
color: #646464;
margin-top: 1mm;
}
.scope-description {
font-size: 9pt;
color: #646464;
margin-top: 1mm;
}
.separator {
border: none;
border-top: 0.5pt solid #e0e0e0;
margin: 3mm 0 5mm 0;
}
/* ---- Addresses ---- */
.addresses {
display: flex;
justify-content: space-between;
margin-bottom: 8mm;
}
.address-block { width: 48%; }
.address-block.right { text-align: right; }
.address-label {
font-size: 9pt;
font-weight: bold;
color: #646464;
line-height: 1.5;
}
.address-name {
font-size: 9pt;
font-weight: bold;
color: #1a1a1a;
line-height: 1.5;
}
.address-line {
font-size: 9pt;
color: #646464;
line-height: 1.5;
}
/* ---- Items table ---- */
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;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 7px 8px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
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: 10pt;
font-weight: 500;
color: #1a1a1a;
}
table.items tbody td.total-cell {
font-weight: 700;
}
.item-subdesc {
font-size: 9pt;
color: #646464;
margin-top: 2px;
font-weight: 400;
}
/* ---- Totals ---- */
.totals-wrapper {
display: flex;
justify-content: flex-end;
break-inside: avoid;
margin-top: 8mm;
}
.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: #646464;
margin-bottom: 2mm;
}
.totals .row:last-child { margin-bottom: 0; }
.totals .row .value {
color: #1a1a1a;
font-size: 8.5pt;
}
.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: #646464;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .exchange-rate {
text-align: right;
font-size: 7.5pt;
color: #969696;
margin-top: 3mm;
}
/* ---- Scope sections ---- */
.scope-page {
page-break-before: always;
}
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: bold;
color: #1a1a1a;
margin-bottom: 1mm;
}
.section-content {
font-size: 9pt;
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* ---- Repeating page header ---- */
table.page-layout {
width: 100%;
border-collapse: collapse;
}
table.page-layout > thead > tr > td,
table.page-layout > tbody > tr > td {
padding: 0;
border: none;
vertical-align: top;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
@bottom-center {
content: "' . $esc($pageLabel) . ' " counter(page) " ' . $esc($ofLabel) . ' " counter(pages);
font-size: 8pt;
color: #969696;
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
}
}
}
/* ---- Screen-only: A4 page preview ---- */
@media screen {
html {
background: #525659;
}
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
.quotation-page, .scope-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;
}
/* On screen: neutralize the table layout used for print repeating headers (quotation page only) */
table.page-layout,
table.page-layout > thead,
table.page-layout > thead > tr,
table.page-layout > thead > tr > td,
table.page-layout > tbody,
table.page-layout > tbody > tr,
table.page-layout > tbody > tr > td {
display: block;
width: 100%;
}
/* On screen: undo print-specific hacks */
.first-content {
margin-top: 0 !important;
}
/* On screen: show logo-header normally as right-aligned block */
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">' . $logoImg . '</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header">
<div class="left">
<div class="page-title">' . $esc($t('title')) . '</div>
<div class="quotation-number">' . $quotationNumber . '</div>
' . (!empty($quotation['project_code'])
? '<div class="project-code">' . $esc($quotation['project_code']) . '</div>'
: '') . '
<div class="valid-until">' . $esc($t('valid_until')) . ': '
. $esc($formatDate($quotation['valid_until'] ?? '')) . '</div>
</div>
</div>
<hr class="separator" />
<div class="addresses">
<div class="address-block left">
<div class="address-label">' . $esc($t('customer')) . '</div>
<div class="address-name">' . $esc($cust['name']) . '</div>
' . $custLinesHtml . '
</div>
<div class="address-block right">
<div class="address-label">' . $esc($t('supplier')) . '</div>
<div class="address-name">' . $esc($supp['name']) . '</div>
' . $suppLinesHtml . '
</div>
</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:5%">' . $esc($t('no')) . '</th>
<th style="width:44%">' . $esc($t('description')) . '</th>
<th class="center" style="width:13%">' . $esc($t('qty')) . '</th>
<th class="right" style="width:18%">' . $esc($t('unit_price')) . '</th>
<th class="right" style="width:20%">' . $esc($t('total')) . '</th>
</tr>
</thead>
<tbody>
' . $itemsHtml . '
</tbody>
</table>
<div class="totals-wrapper">
<div class="totals">
' . $totalsHtml . '
</div>
</div>
</div>
</td></tr>
</tbody>
</table>
</div>
' . $scopeHtml . '
</body>
</html>';
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit();
} catch (PDOException $e) {
error_log('Offers 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('Offers 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);
}
}