< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Public_Views_AssemblyList_Index
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Public/Views/AssemblyList/Index.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 9
Coverable lines: 9
Total lines: 990
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 80
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()0%272160%
<ExecuteAsync()0%3660600%
<ExecuteAsync()0%2040%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Public/Views/AssemblyList/Index.cshtml

#LineLine coverage
 1@{
 02    Layout = null;
 03    var accessKey = (string)ViewBag.AccessKey;
 04    var baseUrl   = $"/public/assembly/{accessKey}";
 05    var periodFrom = (string)ViewBag.From!;
 06    var periodTo = (string)ViewBag.To!;
 7}
 8<!DOCTYPE html>
 9<html lang="ru">
 10<head>
 11    <meta charset="utf-8" />
 12    <meta name="viewport" content="width=device-width, initial-scale=1" />
 013    <title>@Html.Raw(OgHtml.Attribute((string)ViewBag.OgTitle))</title>
 14    <meta name="description" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgDescription))" />
 15    <link rel="icon" type="image/svg+xml" href="~/favicon.svg" asp-append-version="true" />
 16    <link rel="apple-touch-icon" href="~/favicon.svg" asp-append-version="true" />
 17
 18    <meta property="og:type" content="website" />
 19    <meta property="og:site_name" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgSiteName))" />
 20    <meta property="og:locale" content="ru_RU" />
 21    <meta property="og:title" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgTitle))" />
 22    <meta property="og:description" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgDescription))" />
 23    <meta property="og:url" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgUrl))" />
 24    <meta property="og:image" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgImageUrl))" />
 25    <meta property="og:image:secure_url" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgImageUrl))" />
 26    <meta property="og:image:type" content="image/jpeg" />
 27    <meta property="og:image:width" content="1200" />
 28    <meta property="og:image:height" content="630" />
 29    <meta property="og:image:alt" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgImageAlt))" />
 30
 31    <meta name="twitter:card" content="summary_large_image" />
 32    <meta name="twitter:title" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgTitle))" />
 33    <meta name="twitter:description" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgDescription))" />
 34    <meta name="twitter:image" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgImageUrl))" />
 35    <meta name="twitter:image:alt" content="@Html.Raw(OgHtml.Attribute((string)ViewBag.OgImageAlt))" />
 36
 37    <meta name="theme-color" content="#2563EB" />
 38    <meta name="application-name" content="Interparts" />
 39
 40    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="a
 41    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" crossorigin=
 42    <link rel="stylesheet" href="~/css/design-tokens.css" asp-append-version="true" />
 43    <link rel="stylesheet" href="~/css/Public/assembly-list-public.css" asp-append-version="true" />
 44</head>
 45<body class="pal-page">
 46
 47<div id="splash" class="pal-splash">
 48    <div class="pal-splash-logo">
 49        <img src="~/favicon.svg" asp-append-version="true" alt="" width="36" height="36" />
 50        <span>Interparts</span>
 51    </div>
 52    <div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div>
 53    <div class="pal-splash-text" id="splashText">Подготавливаем лист подбора...</div>
 54</div>
 55
 56<div class="pal-container">
 57
 58    <header class="pal-hero">
 59        <div class="pal-hero-brand">
 60            <img src="~/favicon.svg" asp-append-version="true" alt="" width="22" height="22" />
 61            <span>Interparts · FBS</span>
 62        </div>
 63        <div class="pal-hero-top">
 64            <div>
 65                <h1><i class="fas fa-list-alt text-primary me-2"></i>Лист подбора FBS</h1>
 66                <div class="pal-hero-subtitle" id="pageSubtitle"></div>
 67            </div>
 68        </div>
 69        <div class="pal-hero-meta" id="heroMeta"></div>
 70    </header>
 71
 72    <div id="progressSection" class="pal-panel" style="display:none;">
 73        <div class="pal-panel-header"><i class="fas fa-cogs me-2"></i>Формирование листа</div>
 74        <div class="pal-panel-body">
 75            <div class="pal-steps">
 76                <div class="pal-step" id="step-1">
 77                    <div class="pal-step-icon"><i class="fas fa-download" id="step-icon-1"></i><span class="spinner-bord
 78                    <div><div class="pal-step-title">Шаг 1: Отправления</div><div class="pal-step-msg" id="step-msg-1"><
 79                </div>
 80                <div class="pal-step" id="step-2">
 81                    <div class="pal-step-icon"><i class="fas fa-images" id="step-icon-2"></i><span class="spinner-border
 82                    <div><div class="pal-step-title">Шаг 2: Товары</div><div class="pal-step-msg" id="step-msg-2"></div>
 83                </div>
 84                <div class="pal-step" id="step-3">
 85                    <div class="pal-step-icon"><i class="fas fa-tags" id="step-icon-3"></i><span class="spinner-border s
 86                    <div><div class="pal-step-title">Шаг 3: Этикетки</div><div class="pal-step-msg" id="step-msg-3"></di
 87                </div>
 88                <div class="pal-step" id="step-4">
 89                    <div class="pal-step-icon"><i class="fas fa-database" id="step-icon-4"></i><span class="spinner-bord
 90                    <div><div class="pal-step-title">Шаг 4: Остатки 1С</div><div class="pal-step-msg" id="step-msg-4"></
 91                </div>
 92                <div class="pal-step" id="step-5">
 93                    <div class="pal-step-icon"><i class="fas fa-table" id="step-icon-5"></i><span class="spinner-border 
 94                    <div><div class="pal-step-title">Шаг 5: Сборка</div><div class="pal-step-msg" id="step-msg-5"></div>
 95                </div>
 96            </div>
 97            <div id="progressError" class="alert alert-danger mt-3 d-none mb-0"></div>
 98        </div>
 99    </div>
 100
 101    <div id="resultSection" class="pal-panel" style="display:none;">
 102        <div class="pal-result-header">
 103            <div>
 104                <p class="pal-result-title mb-0"><i class="fas fa-boxes-stacked text-primary me-2"></i>Позиции к подбору
 105                <div class="pal-result-meta" id="resultMeta"></div>
 106            </div>
 107            <div class="pal-result-actions">
 108                <a id="downloadAllLabelsBtn"
 109                   class="pal-download-all-btn d-none"
 110                   href="#">
 111                    <i class="fas fa-file-pdf pal-download-all-btn-icon" aria-hidden="true"></i>
 112                    <span class="pal-download-all-btn-text">Этикетки одним PDF</span>
 113                    <span class="spinner-border spinner-border-sm pal-download-all-btn-spinner d-none" role="status" ari
 114                </a>
 115                <a id="exportExcelBtn"
 116                   class="pal-download-all-btn pal-export-excel-btn d-none"
 117                   href="#">
 118                    <i class="fas fa-file-excel pal-download-all-btn-icon" aria-hidden="true"></i>
 119                    <span class="pal-download-all-btn-text">Экспорт в Excel</span>
 120                    <span class="spinner-border spinner-border-sm pal-download-all-btn-spinner d-none" role="status" ari
 121                </a>
 122                <span class="pal-result-badge" id="resultBadge"></span>
 123            </div>
 124        </div>
 125
 126        <div class="pal-1c-hint" id="onecHint">
 127            <i class="fas fa-circle-info"></i>
 128            <span>Поля <strong>Отправление</strong> и <strong>Артикул</strong> копируются по клику.</span>
 129        </div>
 130
 131        <div class="pal-table-wrap">
 132            <table class="table pal-table mb-0" id="assemblyTable">
 133                <thead>
 134                    <tr>
 135                        <th class="text-center" style="width:40px;">№</th>
 136                        <th>Отправление</th>
 137                        <th>Артикул</th>
 138                        <th>Название</th>
 139                        <th>Склад</th>
 140                        <th>Статус</th>
 141                        <th class="text-center" style="width:52px;">Кол.</th>
 142                        <th>Поставщик</th>
 143                        <th>Заказан</th>
 144                        <th class="text-center">1С</th>
 145                        <th>Приход</th>
 146                        <th>Этикетка</th>
 147                    </tr>
 148                </thead>
 149                <tbody id="assemblyTableBody"></tbody>
 150            </table>
 151        </div>
 152
 153        <div class="pal-cards" id="assemblyCards" aria-label="Список позиций"></div>
 154
 155        <div id="emptyWarehouse" class="pal-empty d-none">
 156            <div><i class="fas fa-box-open d-block"></i></div>
 157            <div>Нет отправлений для выбранного склада.</div>
 158        </div>
 159    </div>
 160
 161</div>
 162
 163<div id="palImageLightbox" class="pal-image-lightbox d-none" role="dialog" aria-modal="true" aria-label="Просмотр изобра
 164    <button type="button" id="palImageLightboxClose" class="pal-image-lightbox-close" aria-label="Закрыть">
 165        <i class="fas fa-times" aria-hidden="true"></i>
 166    </button>
 167    <img id="palImageLightboxImg" class="pal-image-lightbox-img" src="" alt="Изображение товара" />
 168</div>
 169
 170<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></scr
 171<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script>
 172<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js" crossorigin="anonymous"></script>
 173<script>
 174pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
 175
 176(function () {
 0177    var BASE_URL = '@Html.Raw(baseUrl)';
 178    var params   = new URLSearchParams(location.search);
 179    var clientId  = params.get('clientId');
 180    var status    = params.get('status') || 'awaiting_deliver';
 0181    var from      = '@periodFrom';
 0182    var to        = '@periodTo';
 183    var warehouse = params.get('warehouse') || '';
 184
 185    var splash = document.getElementById('splash');
 186    var splashText = document.getElementById('splashText');
 187    var heroMeta = document.getElementById('heroMeta');
 188    var labelThumbDataUrlCache = new Map();
 189    var labelThumbRenderPromiseCache = new Map();
 190    var labelExportImageCache = new Map();
 191    var labelExportRenderPromiseCache = new Map();
 192    var productExportImageCache = new Map();
 193    var productExportRenderPromiseCache = new Map();
 194    var currentJobId = '';
 195    var isDownloadingLabels = false;
 196    var isExportingExcel = false;
 197    var lastResultItems = [];
 198    var lastResultMeta = { generatedAt: null };
 199    var imageLightbox = document.getElementById('palImageLightbox');
 200    var imageLightboxImg = document.getElementById('palImageLightboxImg');
 201
 202    var splashPhrases = [
 203        'Подготавливаем лист подбора...',
 204        'Запрашиваем данные у Ozon...',
 205        'Собираем отправления...',
 206        'Загружаем информацию о товарах...',
 207        'Проверяем остатки на складе...',
 208        'Запрашиваем этикетки...',
 209        'Сверяем данные с 1С...',
 210        'Ещё немного, почти готово...'
 211    ];
 212    var splashIdx = 0;
 213    var splashTimer = setInterval(function () {
 214        splashIdx = (splashIdx + 1) % splashPhrases.length;
 215        if (splashText) splashText.textContent = splashPhrases[splashIdx];
 216    }, 2000);
 217
 218    function hideSplash() {
 219        clearInterval(splashTimer);
 220        splash.classList.add('hidden');
 221        setTimeout(function () { splash.style.display = 'none'; }, 450);
 222    }
 223
 224    function showError(msg) {
 225        hideSplash();
 226        document.getElementById('progressSection').style.display = '';
 227        document.getElementById('progressError').textContent = msg;
 228        document.getElementById('progressError').classList.remove('d-none');
 229    }
 230
 231    function escHtml(s) {
 232        if (!s) return '';
 233        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 234    }
 235
 236    function fmtDate(d) {
 237        try { return new Date(d).toLocaleDateString('ru-RU'); } catch { return d; }
 238    }
 239
 240    function fmtDateTime(d) {
 241        try {
 242            return new Date(d).toLocaleString('ru-RU', {
 243                day: '2-digit', month: '2-digit', year: 'numeric',
 244                hour: '2-digit', minute: '2-digit'
 245            });
 246        } catch { return d; }
 247    }
 248
 249    function statusBadgeClass(st) {
 250        return {
 251            awaiting_deliver:   'bg-primary',
 252            awaiting_packaging: 'bg-warning text-dark',
 253            delivering:         'bg-info text-dark',
 254            cancelled:          'bg-danger',
 255            not_accepted:       'bg-danger'
 256        }[st] || 'bg-secondary';
 257    }
 258
 259    function copyPalValue(el) {
 260        var text = (el.getAttribute('data-copy') || el.textContent || '').trim();
 261        if (!text) return;
 262
 263        function onCopied() {
 264            el.classList.add('pal-copied');
 265            setTimeout(function () { el.classList.remove('pal-copied'); }, 1200);
 266            var short = text.length > 36 ? text.substring(0, 36) + '…' : text;
 267            showPalToast('<i class="fas fa-check-circle"></i><span>Скопировано: <code class="pal-code">' + escHtml(short
 268        }
 269
 270        if (navigator.clipboard && window.isSecureContext) {
 271            navigator.clipboard.writeText(text).then(onCopied).catch(function () { fallbackCopy(text, onCopied); });
 272        } else {
 273            fallbackCopy(text, onCopied);
 274        }
 275    }
 276
 277    function fallbackCopy(text, onSuccess) {
 278        var ta = document.createElement('textarea');
 279        ta.value = text;
 280        ta.style.position = 'fixed';
 281        ta.style.opacity = '0';
 282        document.body.appendChild(ta);
 283        ta.select();
 284        try {
 285            if (document.execCommand('copy')) onSuccess();
 286            else showPalToast('<i class="fas fa-times-circle text-danger"></i><span>Не удалось скопировать</span>');
 287        } catch (e) {
 288            showPalToast('<i class="fas fa-times-circle text-danger"></i><span>Не удалось скопировать</span>');
 289        } finally {
 290            document.body.removeChild(ta);
 291        }
 292    }
 293
 294    window.copyPalValue = copyPalValue;
 295
 296    function showPalToast(html) {
 297        var container = document.getElementById('pal-toast-container');
 298        if (!container) {
 299            container = document.createElement('div');
 300            container.id = 'pal-toast-container';
 301            container.className = 'pal-toast-container';
 302            document.body.appendChild(container);
 303        }
 304        var toast = document.createElement('div');
 305        toast.className = 'pal-toast';
 306        toast.innerHTML = html;
 307        container.appendChild(toast);
 308        setTimeout(function () {
 309            toast.style.opacity = '0';
 310            toast.style.transition = 'opacity 0.3s';
 311            setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 320);
 312        }, 2200);
 313    }
 314
 315    function copyCell(value) {
 316        return '<span class="pal-code pal-copy pal-code-nowrap" role="button" tabindex="0" data-copy="' + escHtml(value)
 317    }
 318
 319    function productImageHtml(imgSrc, wrapExtraClass) {
 320        if (!imgSrc) return '';
 321        var wrapCls = 'pal-media-wrap pal-img-wrap' + (wrapExtraClass ? ' ' + wrapExtraClass : '');
 322        return '<span class="' + wrapCls + '">' +
 323            '<span class="pal-skeleton pal-skeleton-img" aria-hidden="true"></span>' +
 324            '<img src="' + imgSrc + '" class="pal-table-img pal-media-item pal-product-zoomable" loading="lazy" alt="Тов
 325            'onload="palOnImgLoad(this)" onerror="palOnImgError(this)">' +
 326            '</span>';
 327    }
 328
 329    function labelThumbHtml(labelProxyUrl) {
 330        return '<a href="' + labelProxyUrl + '" download class="pal-label-thumb-link pal-media-wrap pal-label-wrap" titl
 331            '<span class="pal-skeleton pal-skeleton-label" aria-hidden="true"></span>' +
 332            '<canvas class="pal-label-thumb pal-media-item" data-pdf="' + escHtml(labelProxyUrl) + '"></canvas></a>';
 333    }
 334
 335    window.palOnImgLoad = function (img) {
 336        var wrap = img.closest('.pal-media-wrap');
 337        if (wrap) wrap.classList.add('is-loaded');
 338    };
 339
 340    window.palOnImgError = function (img) {
 341        var wrap = img.closest('.pal-media-wrap');
 342        if (wrap) {
 343            wrap.classList.add('is-error');
 344            img.remove();
 345        }
 346    };
 347
 348    function hydrateCachedImages(root) {
 349        (root || document).querySelectorAll('.pal-img-wrap img').forEach(function (img) {
 350            if (!img.complete) return;
 351            if (img.naturalWidth > 0) palOnImgLoad(img);
 352            else palOnImgError(img);
 353        });
 354    }
 355
 356    function buildItemParts(item) {
 357        var statusClass = statusBadgeClass(item.status);
 358        var imgSrc = item.imageUrl ? (BASE_URL + '/image-proxy?url=' + encodeURIComponent(item.imageUrl)) : '';
 359
 360        var balanceInner;
 361        if (item.oneCBalance != null) {
 362            var balanceClass = item.oneCBalance > 0 ? 'text-success' : 'text-danger';
 363            balanceInner = '<span class="pal-1c-balance-main ' + balanceClass + '">' + item.oneCBalance + '</span>';
 364        } else {
 365            balanceInner = '<span class="text-muted">—</span>';
 366        }
 367        if (item.oneCWarehouseName) {
 368            balanceInner += '<span class="pal-1c-warehouse">' + escHtml(item.oneCWarehouseName) + '</span>';
 369        }
 370
 371        var balanceHtml = balanceInner;
 372
 373        var receiptHtml = escHtml(item.oneCReceiptDoc || '—');
 374
 375        var labelProxyUrl = item.labelUrl ? (BASE_URL + '/label-proxy?url=' + encodeURIComponent(item.labelUrl)) : '';
 376        var labelHtml = labelProxyUrl
 377            ? labelThumbHtml(labelProxyUrl)
 378            : (item.labelError
 379                ? '<span class="text-muted small" title="' + escHtml(item.labelError) + '"><i class="fas fa-exclamation-
 380                : '<span class="text-muted">—</span>');
 381
 382        var orderedDate = item.orderedToSupplierDate ? fmtDateTime(item.orderedToSupplierDate) : '—';
 383        var nameHtml = '<div class="pal-product-cell">' +
 384            productImageHtml(imgSrc) +
 385            '<span class="pal-product-name">' + escHtml(item.productName) + '</span></div>';
 386
 387        return {
 388            statusClass: statusClass,
 389            balanceHtml: balanceHtml,
 390            receiptHtml: receiptHtml,
 391            labelHtml: labelHtml,
 392            labelProxyUrl: labelProxyUrl,
 393            orderedDate: orderedDate,
 394            nameHtml: nameHtml,
 395            imgSrc: imgSrc
 396        };
 397    }
 398
 399    function renderLabelThumb(canvas) {
 400        if (!canvas || !window.pdfjsLib) return;
 401        var wrap = canvas.closest('.pal-label-wrap');
 402        var pdfUrl = canvas.dataset.pdf;
 403        if (!pdfUrl) return;
 404        var inCardFooter = !!canvas.closest('.pal-card-footer');
 405        var cacheVariant = inCardFooter ? 'card' : 'table';
 406        var cacheKey = pdfUrl + '|' + cacheVariant;
 407
 408        function markLoaded() {
 409            if (wrap) wrap.classList.add('is-loaded');
 410        }
 411
 412        function markError() {
 413            if (wrap) {
 414                wrap.classList.add('is-error', 'is-loaded');
 415            }
 416            canvas.width = 58;
 417            canvas.height = 20;
 418            var ctx = canvas.getContext('2d');
 419            ctx.fillStyle = '#fff3cd';
 420            ctx.fillRect(0, 0, 58, 20);
 421            ctx.fillStyle = '#856404';
 422            ctx.font = '10px sans-serif';
 423            ctx.textAlign = 'center';
 424            ctx.fillText('PDF', 29, 14);
 425        }
 426
 427        function applyDataUrl(dataUrl) {
 428            var img = new Image();
 429            img.onload = function () {
 430                canvas.width = img.width;
 431                canvas.height = img.height;
 432                var ctx = canvas.getContext('2d');
 433                ctx.clearRect(0, 0, canvas.width, canvas.height);
 434                ctx.drawImage(img, 0, 0);
 435                markLoaded();
 436            };
 437            img.onerror = markError;
 438            img.src = dataUrl;
 439        }
 440
 441        var cachedDataUrl = labelThumbDataUrlCache.get(cacheKey);
 442        if (cachedDataUrl) {
 443            applyDataUrl(cachedDataUrl);
 444            return;
 445        }
 446
 447        var existingPromise = labelThumbRenderPromiseCache.get(cacheKey);
 448        if (existingPromise) {
 449            existingPromise.then(applyDataUrl).catch(markError);
 450            return;
 451        }
 452
 453        var renderPromise = pdfjsLib.getDocument(pdfUrl).promise.then(function (pdf) {
 454            return pdf.getPage(1);
 455        }).then(function (page) {
 456            var vp = page.getViewport({ scale: 1 });
 457            var maxW = inCardFooter ? 96 : 72;
 458            var maxH = inCardFooter ? 132 : 108;
 459            var scale = Math.min(maxW / vp.width, maxH / vp.height);
 460            var viewport = page.getViewport({ scale: scale });
 461            var offscreen = document.createElement('canvas');
 462            offscreen.width = viewport.width;
 463            offscreen.height = viewport.height;
 464            return page.render({ canvasContext: offscreen.getContext('2d'), viewport: viewport }).promise
 465                .then(function () { return offscreen.toDataURL('image/png'); });
 466        });
 467
 468        labelThumbRenderPromiseCache.set(cacheKey, renderPromise);
 469        renderPromise.then(function (dataUrl) {
 470            labelThumbDataUrlCache.set(cacheKey, dataUrl);
 471            applyDataUrl(dataUrl);
 472        }).catch(function () {
 473            markError();
 474        }).finally(function () {
 475            labelThumbRenderPromiseCache.delete(cacheKey);
 476        });
 477    }
 478
 479    function renderResults(items) {
 480        var tbody = document.getElementById('assemblyTableBody');
 481        var cards = document.getElementById('assemblyCards');
 482        tbody.innerHTML = '';
 483        cards.innerHTML = '';
 484
 485        var lastPosting = '';
 486        var labelCanvases = [];
 487
 488        items.forEach(function (item) {
 489            var parts = buildItemParts(item);
 490            var isNewPosting = item.postingNumber !== lastPosting;
 491
 492            var tr = document.createElement('tr');
 493            if (isNewPosting) {
 494                tr.className = 'pal-row-posting-start';
 495                lastPosting = item.postingNumber;
 496            }
 497
 498            tr.innerHTML =
 499                '<td class="text-center text-muted small">' + item.index + '</td>' +
 500                '<td class="pal-col-posting">' + copyCell(item.postingNumber) + '</td>' +
 501                '<td>' + copyCell(item.offerId) + '</td>' +
 502                '<td class="small">' + parts.nameHtml + '</td>' +
 503                '<td class="small">' + escHtml(item.warehouseName) + '</td>' +
 504                '<td><span class="badge ' + parts.statusClass + '">' + escHtml(item.statusLabel) + '</span></td>' +
 505                '<td class="text-center fw-bold">' + item.quantity + '</td>' +
 506                '<td class="small">' + escHtml(item.supplierName || '—') + '</td>' +
 507                '<td class="small">' + parts.orderedDate + '</td>' +
 508                '<td class="text-center small">' + parts.balanceHtml + '</td>' +
 509                '<td class="small">' + parts.receiptHtml + '</td>' +
 510                '<td>' + parts.labelHtml + '</td>';
 511            tbody.appendChild(tr);
 512
 513            var postingHeaderHtml = isNewPosting
 514                ? '<div class="pal-posting-divider pal-card-posting"><i class="fas fa-box"></i><span>Отправление</span>'
 515                : '';
 516
 517            var card = document.createElement('article');
 518            card.className = 'pal-card';
 519            card.innerHTML =
 520                postingHeaderHtml +
 521                '<div class="pal-card-head">' +
 522                    '<span class="pal-card-index">#' + item.index + '</span>' +
 523                    '<span class="badge ' + parts.statusClass + '">' + escHtml(item.statusLabel) + '</span>' +
 524                    '<span class="pal-card-qty">×' + item.quantity + '</span>' +
 525                '</div>' +
 526                '<div class="pal-card-product">' +
 527                    productImageHtml(parts.imgSrc) +
 528                    '<div class="pal-card-name">' + escHtml(item.productName) + '</div>' +
 529                '</div>' +
 530                '<dl class="pal-card-meta">' +
 531                    '<div class="pal-meta-full"><dt>Артикул</dt><dd>' + copyCell(item.offerId) + '</dd></div>' +
 532                    '<div><dt>Склад</dt><dd>' + escHtml(item.warehouseName) + '</dd></div>' +
 533                    '<div class="pal-meta-full"><dt>Поставщик</dt><dd>' + escHtml(item.supplierName || '—') + '</dd></di
 534                    '<div><dt>Заказан</dt><dd>' + parts.orderedDate + '</dd></div>' +
 535                    '<div><dt>Остаток 1С</dt><dd>' + parts.balanceHtml + '</dd></div>' +
 536                    '<div class="pal-meta-full"><dt>Приход 1С</dt><dd>' + parts.receiptHtml + '</dd></div>' +
 537                '</dl>' +
 538                '<div class="pal-card-footer">' +
 539                    '<span class="pal-card-footer-label">Этикетка</span>' +
 540                    parts.labelHtml +
 541                '</div>';
 542            cards.appendChild(card);
 543
 544            if (parts.labelProxyUrl) {
 545                var canvas = card.querySelector('canvas.pal-label-thumb');
 546                if (canvas) labelCanvases.push(canvas);
 547            }
 548        });
 549
 550        labelCanvases.forEach(renderLabelThumb);
 551        tbody.querySelectorAll('canvas.pal-label-thumb').forEach(renderLabelThumb);
 552        hydrateCachedImages(tbody);
 553        hydrateCachedImages(cards);
 554    }
 555
 556    function setGeneratedMeta(data) {
 557        var generatedAt = data.generatedAt ? fmtDateTime(data.generatedAt) : '—';
 558        var metaText = 'Сформировано: ' + generatedAt +
 559            ' · ' + data.items.length + ' строк · ' + (data.totalPostings || 0) + ' отправлений';
 560        document.getElementById('resultMeta').textContent = metaText;
 561
 562        heroMeta.innerHTML =
 563            '<span class="pal-chip"><i class="fas fa-calendar"></i>' + fmtDate(from) + ' – ' + fmtDate(to) + '</span>' +
 564            (warehouse ? '<span class="pal-chip"><i class="fas fa-warehouse"></i>' + escHtml(warehouse) + '</span>' : ''
 565            '<span class="pal-chip pal-chip-generated" id="generatedAtChip"><i class="fas fa-clock"></i>' + generatedAt 
 566    }
 567
 568    function updateDownloadAllLabelsButton(items) {
 569        var btn = document.getElementById('downloadAllLabelsBtn');
 570        if (!btn) return;
 571
 572        var hasLabels = Array.isArray(items) && items.some(function (it) { return !!it.labelUrl; });
 573        if (!currentJobId || !hasLabels) {
 574            btn.classList.add('d-none');
 575            btn.removeAttribute('href');
 576            return;
 577        }
 578
 579        var qs = new URLSearchParams({
 580            jobId: currentJobId,
 581            clientId: String(parseInt(clientId))
 582        });
 583        btn.href = BASE_URL + '/download-labels?' + qs.toString();
 584        btn.classList.remove('d-none');
 585    }
 586
 587    function setActionButtonLoading(btn, loading, loadingText, idleText) {
 588        if (!btn) return;
 589        var text = btn.querySelector('.pal-download-all-btn-text');
 590        var spinner = btn.querySelector('.pal-download-all-btn-spinner');
 591        if (loading) {
 592            btn.classList.add('is-loading');
 593            btn.setAttribute('aria-disabled', 'true');
 594            if (text) text.textContent = loadingText;
 595            if (spinner) spinner.classList.remove('d-none');
 596        } else {
 597            btn.classList.remove('is-loading');
 598            btn.removeAttribute('aria-disabled');
 599            if (text) text.textContent = idleText;
 600            if (spinner) spinner.classList.add('d-none');
 601        }
 602    }
 603
 604    function setDownloadBtnState(loading) {
 605        setActionButtonLoading(document.getElementById('downloadAllLabelsBtn'), loading, 'Подготавливаем PDF...', 'Этике
 606    }
 607
 608    function updateExportExcelButton(items) {
 609        var btn = document.getElementById('exportExcelBtn');
 610        if (!btn) return;
 611        btn.classList.toggle('d-none', !Array.isArray(items) || items.length === 0);
 612    }
 613
 614    function fmtFileDate(d) {
 615        try {
 616            var dt = new Date(d || Date.now());
 617            var pad = function (n) { return (n < 10 ? '0' : '') + n; };
 618            return dt.getFullYear() + pad(dt.getMonth() + 1) + pad(dt.getDate()) + '_' + pad(dt.getHours()) + pad(dt.get
 619        } catch { return 'export'; }
 620    }
 621
 622    function getLabelExportImage(pdfUrl) {
 623        if (!pdfUrl || !window.pdfjsLib) return Promise.resolve(null);
 624        if (labelExportImageCache.has(pdfUrl)) return Promise.resolve(labelExportImageCache.get(pdfUrl));
 625
 626        var existingPromise = labelExportRenderPromiseCache.get(pdfUrl);
 627        if (existingPromise) return existingPromise;
 628
 629        var renderPromise = pdfjsLib.getDocument(pdfUrl).promise.then(function (pdf) {
 630            return pdf.getPage(1);
 631        }).then(function (page) {
 632            var vp = page.getViewport({ scale: 1 });
 633            var scale = Math.min(140 / vp.width, 200 / vp.height);
 634            var viewport = page.getViewport({ scale: scale });
 635            var canvas = document.createElement('canvas');
 636            canvas.width = viewport.width;
 637            canvas.height = viewport.height;
 638            return page.render({ canvasContext: canvas.getContext('2d'), viewport: viewport }).promise.then(function () 
 639                var result = { dataUrl: canvas.toDataURL('image/png'), extension: 'png', width: viewport.width, height: 
 640                labelExportImageCache.set(pdfUrl, result);
 641                return result;
 642            });
 643        }).catch(function () {
 644            return null;
 645        }).finally(function () {
 646            labelExportRenderPromiseCache.delete(pdfUrl);
 647        });
 648
 649        labelExportRenderPromiseCache.set(pdfUrl, renderPromise);
 650        return renderPromise;
 651    }
 652
 653    function getProductExportImage(imgUrl) {
 654        if (!imgUrl) return Promise.resolve(null);
 655        if (productExportImageCache.has(imgUrl)) return Promise.resolve(productExportImageCache.get(imgUrl));
 656
 657        var existingPromise = productExportRenderPromiseCache.get(imgUrl);
 658        if (existingPromise) return existingPromise;
 659
 660        var renderPromise = fetch(imgUrl)
 661            .then(function (r) { if (!r.ok) throw new Error('image fetch failed'); return r.blob(); })
 662            .then(function (blob) {
 663                return new Promise(function (resolve, reject) {
 664                    var reader = new FileReader();
 665                    reader.onload = function () { resolve(reader.result); };
 666                    reader.onerror = reject;
 667                    reader.readAsDataURL(blob);
 668                });
 669            })
 670            .then(function (dataUrl) {
 671                return new Promise(function (resolve) {
 672                    var img = new Image();
 673                    img.onload = function () {
 674                        var extMatch = /^data:image\/(png|jpe?g|gif)/i.exec(dataUrl);
 675                        var extension = extMatch ? (extMatch[1].toLowerCase() === 'jpg' ? 'jpeg' : extMatch[1].toLowerCa
 676                        var result = { dataUrl: dataUrl, extension: extension, width: img.naturalWidth || 60, height: im
 677                        productExportImageCache.set(imgUrl, result);
 678                        resolve(result);
 679                    };
 680                    img.onerror = function () { resolve(null); };
 681                    img.src = dataUrl;
 682                });
 683            })
 684            .catch(function () { return null; })
 685            .finally(function () { productExportRenderPromiseCache.delete(imgUrl); });
 686
 687        productExportRenderPromiseCache.set(imgUrl, renderPromise);
 688        return renderPromise;
 689    }
 690
 691    function placeCellImage(workbook, sheet, imgPromise, col0, row0, maxW, maxH) {
 692        return imgPromise.then(function (img) {
 693            if (!img) return;
 694            var imgId = workbook.addImage({ base64: img.dataUrl, extension: img.extension });
 695            var scale = Math.min(1, maxW / img.width, maxH / img.height);
 696            sheet.addImage(imgId, {
 697                tl: { col: col0 + 0.05, row: row0 + 0.05 },
 698                ext: { width: img.width * scale, height: img.height * scale }
 699            });
 700        });
 701    }
 702
 703    function exportToExcel() {
 704        if (!lastResultItems.length || !window.ExcelJS) return;
 705        isExportingExcel = true;
 706        setActionButtonLoading(document.getElementById('exportExcelBtn'), true, 'Формируем Excel...', 'Экспорт в Excel')
 707
 708        var workbook = new ExcelJS.Workbook();
 709        workbook.creator = 'Interparts Studio2';
 710        workbook.created = new Date();
 711
 712        var sheet = workbook.addWorksheet('Лист подбора', { views: [{ state: 'frozen', ySplit: 1 }] });
 713        var columns = [
 714            { header: '№', key: 'index', width: 6 },
 715            { header: 'Фото', key: 'photo', width: 12 },
 716            { header: 'Отправление', key: 'posting', width: 20 },
 717            { header: 'Артикул', key: 'offerId', width: 16 },
 718            { header: 'Название', key: 'name', width: 42 },
 719            { header: 'Склад', key: 'warehouse', width: 16 },
 720            { header: 'Статус', key: 'status', width: 16 },
 721            { header: 'Кол.', key: 'qty', width: 7 },
 722            { header: 'Поставщик', key: 'supplier', width: 20 },
 723            { header: 'Заказан', key: 'ordered', width: 16 },
 724            { header: 'Остаток 1С', key: 'balance', width: 11 },
 725            { header: 'Склад 1С', key: 'warehouse1c', width: 16 },
 726            { header: 'Приход 1С', key: 'receipt', width: 14 },
 727            { header: 'Этикетка', key: 'label', width: 14 }
 728        ];
 729        sheet.columns = columns;
 730
 731        var headerRow = sheet.getRow(1);
 732        headerRow.eachCell(function (cell) {
 733            cell.font = { bold: true };
 734            cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
 735            cell.border = { bottom: { style: 'thin', color: { argb: 'FF999999' } } };
 736        });
 737
 738        var imagePromises = [];
 739        var rowIndex = 2;
 740        var photoCol = 2, labelCol = columns.length;
 741
 742        lastResultItems.forEach(function (item) {
 743            var row = sheet.getRow(rowIndex);
 744            row.getCell(1).value = item.index;
 745            row.getCell(3).value = item.postingNumber;
 746            row.getCell(4).value = item.offerId;
 747            row.getCell(5).value = item.productName;
 748            row.getCell(6).value = item.warehouseName;
 749            row.getCell(7).value = item.statusLabel;
 750            row.getCell(8).value = item.quantity;
 751            row.getCell(9).value = item.supplierName || '—';
 752            row.getCell(10).value = item.orderedToSupplierDate ? fmtDateTime(item.orderedToSupplierDate) : '—';
 753            row.getCell(11).value = item.oneCBalance != null ? item.oneCBalance : '—';
 754            row.getCell(12).value = item.oneCWarehouseName || '—';
 755            row.getCell(13).value = item.oneCReceiptDoc || '—';
 756            if (!item.labelUrl) row.getCell(labelCol).value = item.labelError ? 'Ошибка' : '—';
 757            if (!item.imageUrl) row.getCell(photoCol).value = '—';
 758
 759            for (var c = 1; c <= columns.length; c++) {
 760                row.getCell(c).border = { bottom: { style: 'thin', color: { argb: 'FFD9D9D9' } } };
 761                row.getCell(c).alignment = { vertical: 'middle' };
 762            }
 763            row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' };
 764            row.getCell(11).alignment = { vertical: 'middle', horizontal: 'center' };
 765
 766            row.height = 60;
 767
 768            if (item.imageUrl) {
 769                var imgProxyUrl = BASE_URL + '/image-proxy?url=' + encodeURIComponent(item.imageUrl);
 770                imagePromises.push(placeCellImage(workbook, sheet, getProductExportImage(imgProxyUrl), photoCol - 1, row
 771            }
 772
 773            if (item.labelUrl) {
 774                var labelProxyUrl = BASE_URL + '/label-proxy?url=' + encodeURIComponent(item.labelUrl);
 775                imagePromises.push(placeCellImage(workbook, sheet, getLabelExportImage(labelProxyUrl), labelCol - 1, row
 776            }
 777
 778            rowIndex++;
 779        });
 780
 781        Promise.all(imagePromises)
 782            .then(function () { return workbook.xlsx.writeBuffer(); })
 783            .then(function (buffer) {
 784                var blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
 785                var fileName = 'assembly_list_' + fmtFileDate(lastResultMeta.generatedAt) + '.xlsx';
 786                triggerBrowserDownload(blob, fileName);
 787                showPalToast('<i class="fas fa-check-circle"></i><span>Excel-файл сформирован</span>');
 788            })
 789            .catch(function (err) {
 790                showPalToast('<i class="fas fa-times-circle text-danger"></i><span>' + escHtml(err.message || 'Не удалос
 791            })
 792            .finally(function () {
 793                isExportingExcel = false;
 794                setActionButtonLoading(document.getElementById('exportExcelBtn'), false, 'Формируем Excel...', 'Экспорт 
 795            });
 796    }
 797
 798    function triggerBrowserDownload(blob, fileName) {
 799        var objUrl = URL.createObjectURL(blob);
 800        var a = document.createElement('a');
 801        a.href = objUrl;
 802        a.download = fileName;
 803        document.body.appendChild(a);
 804        a.click();
 805        a.remove();
 806        setTimeout(function () { URL.revokeObjectURL(objUrl); }, 5000);
 807    }
 808
 809    function closeImageLightbox() {
 810        if (!imageLightbox || imageLightbox.classList.contains('d-none')) return;
 811        imageLightbox.classList.add('d-none');
 812        document.body.classList.remove('pal-lightbox-open');
 813        if (imageLightboxImg) imageLightboxImg.src = '';
 814    }
 815
 816    function openImageLightbox(src) {
 817        if (!imageLightbox || !imageLightboxImg || !src) return;
 818        imageLightboxImg.src = src;
 819        imageLightbox.classList.remove('d-none');
 820        document.body.classList.add('pal-lightbox-open');
 821    }
 822
 823    document.addEventListener('click', function (e) {
 824        var btn = e.target.closest('#downloadAllLabelsBtn');
 825        if (!btn) return;
 826        e.preventDefault();
 827        if (isDownloadingLabels) return;
 828        var href = btn.getAttribute('href') || '';
 829        if (!href || href === '#') return;
 830
 831        isDownloadingLabels = true;
 832        setDownloadBtnState(true);
 833
 834        fetch(href)
 835            .then(function (r) {
 836                if (!r.ok) throw new Error('Ошибка скачивания (код ' + r.status + ')');
 837                var dispo = r.headers.get('Content-Disposition') || '';
 838                var match = /filename="([^"]+)"/i.exec(dispo);
 839                var fileName = (match && match[1]) ? match[1] : ('assembly_labels_' + Date.now() + '.pdf');
 840                return r.blob().then(function (blob) { return { blob: blob, fileName: fileName }; });
 841            })
 842            .then(function (payload) {
 843                triggerBrowserDownload(payload.blob, payload.fileName);
 844                showPalToast('<i class="fas fa-check-circle"></i><span>Скачивание началось</span>');
 845            })
 846            .catch(function (err) {
 847                showPalToast('<i class="fas fa-times-circle text-danger"></i><span>' + escHtml(err.message || 'Не удалос
 848            })
 849            .finally(function () {
 850                isDownloadingLabels = false;
 851                setDownloadBtnState(false);
 852            });
 853    });
 854
 855    document.addEventListener('click', function (e) {
 856        var excelBtn = e.target.closest('#exportExcelBtn');
 857        if (!excelBtn) return;
 858        e.preventDefault();
 859        if (isExportingExcel) return;
 860        exportToExcel();
 861    });
 862
 863    document.addEventListener('click', function (e) {
 864        var img = e.target.closest('.pal-product-zoomable');
 865        if (img) {
 866            e.preventDefault();
 867            openImageLightbox(img.getAttribute('src') || '');
 868            return;
 869        }
 870
 871        if (!imageLightbox || imageLightbox.classList.contains('d-none')) return;
 872        var closeBtn = e.target.closest('#palImageLightboxClose');
 873        if (closeBtn || e.target === imageLightbox) {
 874            e.preventDefault();
 875            closeImageLightbox();
 876        }
 877    });
 878
 879    document.addEventListener('keydown', function (e) {
 880        if (e.key === 'Escape') closeImageLightbox();
 881    });
 882
 883    if (!clientId) {
 884        showError('Неверная ссылка: отсутствует параметр clientId.');
 885        return;
 886    }
 887
 888    document.getElementById('pageSubtitle').textContent =
 889        'Период: ' + fmtDate(from) + ' – ' + fmtDate(to) + (warehouse ? ' · Склад: ' + warehouse : '');
 890
 891    heroMeta.innerHTML =
 892        '<span class="pal-chip"><i class="fas fa-calendar"></i>' + fmtDate(from) + ' – ' + fmtDate(to) + '</span>' +
 893        (warehouse ? '<span class="pal-chip"><i class="fas fa-warehouse"></i>' + escHtml(warehouse) + '</span>' : '') +
 894        '<span class="pal-chip"><i class="fas fa-spinner fa-spin"></i>Обновление данных...</span>';
 895
 896    document.getElementById('progressSection').style.display = '';
 897
 898    fetch(BASE_URL + '/generate', {
 899        method: 'POST',
 900        headers: { 'Content-Type': 'application/json' },
 901        body: JSON.stringify({ clientId: parseInt(clientId), status: status, from: from, to: to, warehouse: warehouse })
 902    })
 903    .then(function (r) {
 904        if (!r.ok) { showError('Ошибка запуска генерации (код ' + r.status + ')'); return; }
 905        return r.json();
 906    })
 907    .then(function (data) {
 908        if (!data || !data.jobId) return;
 909        currentJobId = data.jobId;
 910        listenProgress(data.jobId);
 911    })
 912    .catch(function (e) { showError('Ошибка соединения: ' + e.message); });
 913
 914    function listenProgress(jobId) {
 915        var es = new EventSource(BASE_URL + '/progress?jobId=' + encodeURIComponent(jobId));
 916        es.addEventListener('progress', function (e) {
 917            var p = JSON.parse(e.data);
 918            if (p.step && p.step >= 1 && p.step <= 5) {
 919                var el = document.getElementById('step-' + p.step);
 920                if (el) {
 921                    el.classList.add('active');
 922                    var icon = document.getElementById('step-icon-' + p.step);
 923                    var spin = document.getElementById('step-spin-' + p.step);
 924                    if (icon) icon.classList.add('d-none');
 925                    if (spin) spin.classList.remove('d-none');
 926                    var msg = document.getElementById('step-msg-' + p.step);
 927                    if (msg) msg.textContent = p.message || '';
 928                }
 929                if (p.step > 1) {
 930                    var prev = document.getElementById('step-' + (p.step - 1));
 931                    if (prev) {
 932                        prev.classList.remove('active');
 933                        prev.classList.add('done');
 934                        var pIcon = document.getElementById('step-icon-' + (p.step - 1));
 935                        var pSpin = document.getElementById('step-spin-' + (p.step - 1));
 936                        if (pIcon) { pIcon.className = 'fas fa-check-circle'; pIcon.classList.remove('d-none'); }
 937                        if (pSpin) pSpin.classList.add('d-none');
 938                    }
 939                }
 940                if (p.step === 1) hideSplash();
 941            }
 942            if (p.isComplete) {
 943                es.close();
 944                for (var s = 1; s <= 5; s++) {
 945                    var stepEl = document.getElementById('step-' + s);
 946                    if (stepEl) { stepEl.classList.remove('active'); stepEl.classList.add('done'); }
 947                    var ic = document.getElementById('step-icon-' + s);
 948                    var sp = document.getElementById('step-spin-' + s);
 949                    if (ic) { ic.className = 'fas fa-check-circle'; ic.classList.remove('d-none'); }
 950                    if (sp) sp.classList.add('d-none');
 951                }
 952                loadResult(jobId);
 953            }
 954            if (p.isError) {
 955                es.close();
 956                hideSplash();
 957                showError(p.errorMessage || p.message || 'неизвестная ошибка');
 958            }
 959        });
 960        es.onerror = function () { es.close(); };
 961    }
 962
 963    function loadResult(jobId) {
 964        fetch(BASE_URL + '/result?jobId=' + encodeURIComponent(jobId))
 965        .then(function (r) { return r.json(); })
 966        .then(function (data) {
 967            document.getElementById('resultSection').style.display = '';
 968            if (!data.items || data.items.length === 0) {
 969                document.getElementById('emptyWarehouse').classList.remove('d-none');
 970                document.getElementById('resultBadge').textContent = '0 позиций';
 971                setGeneratedMeta({ generatedAt: data.generatedAt, items: [], totalPostings: 0 });
 972                updateDownloadAllLabelsButton([]);
 973                lastResultItems = [];
 974                updateExportExcelButton([]);
 975                return;
 976            }
 977            document.getElementById('resultBadge').textContent = data.items.length + ' поз.';
 978            setGeneratedMeta(data);
 979            renderResults(data.items);
 980            updateDownloadAllLabelsButton(data.items);
 981            lastResultItems = data.items;
 982            lastResultMeta = { generatedAt: data.generatedAt };
 983            updateExportExcelButton(data.items);
 984        })
 985        .catch(function (e) { showError('Ошибка загрузки результата: ' + e.message); });
 986    }
 987})();
 988</script>
 989</body>
 990</html>