- 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>
880 lines
28 KiB
PHP
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 with regular spaces in text content (not inside tags)
|
|
$html = preg_replace_callback(
|
|
'/(<[^>]*>)|( )/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);
|
|
}
|
|
}
|