templates/admin/dashboard.html.twig line 1

Open in your IDE?
  1. {% extends 'admin/base.html.twig' %}
  2. {% block title %}Главная{% endblock %}
  3. {% block heading %}Главная{% endblock %}
  4. {% block page_actions %}
  5.   <div class="text-sm-end">
  6.     <a class="btn btn-primary" href="{{ path('admin_product_new') }}">
  7.       <i class="mdi mdi-plus me-1"></i>Добавить товар
  8.     </a>
  9.   </div>
  10. {% endblock %}
  11. {% block scripts %}
  12.   <script>
  13.     (() => {
  14.       const panel = document.querySelector("[data-steam-background-panel]");
  15.       if (!panel) return;
  16.       const statusUrl = panel.dataset.statusUrl;
  17.       const stateNode = panel.querySelector("[data-steam-bg-state]");
  18.       const spinner = panel.querySelector("[data-steam-bg-spinner]");
  19.       const progress = panel.querySelector("[data-steam-bg-progress]");
  20.       const failedWrap = panel.querySelector("[data-steam-bg-failed-wrap]");
  21.       const failedList = panel.querySelector("[data-steam-bg-failed-list]");
  22.       const startButton = document.querySelector("[data-steam-bg-start-button]");
  23.       const stopButton = document.querySelector("[data-steam-bg-stop-button]");
  24.       const badgeClasses = ["primary", "success", "warning", "danger", "secondary", "info", "light", "dark"];
  25.       const setText = (selector, value) => {
  26.         const node = panel.querySelector(selector);
  27.         if (node) node.textContent = value ?? "—";
  28.       };
  29.       const escapeHtml = (value) => String(value ?? "")
  30.         .replaceAll("&", "&amp;")
  31.         .replaceAll("<", "&lt;")
  32.         .replaceAll(">", "&gt;")
  33.         .replaceAll('"', "&quot;")
  34.         .replaceAll("'", "&#039;");
  35.       const setBadgeClass = (kind) => {
  36.         if (!stateNode) return;
  37.         badgeClasses.forEach((item) => {
  38.           stateNode.classList.remove(`bg-${item}-subtle`, `text-${item}`);
  39.         });
  40.         stateNode.classList.add(`bg-${kind}-subtle`, `text-${kind}`);
  41.       };
  42.       const renderFailures = (failures) => {
  43.         if (!failedWrap || !failedList) return;
  44.         const list = Array.isArray(failures) ? failures : [];
  45.         failedWrap.classList.toggle("d-none", list.length === 0);
  46.         failedList.innerHTML = list.slice(0, 20).map((failure) => `
  47.           <tr>
  48.             <td class="text-nowrap">${failure.id ? `#${escapeHtml(failure.id)}` : "—"}</td>
  49.             <td>${escapeHtml(failure.name || "Товар")}</td>
  50.             <td class="text-muted">${escapeHtml(failure.reason || "Ошибка Steam")}</td>
  51.           </tr>
  52.         `).join("");
  53.       };
  54.       const renderStatus = (status) => {
  55.         const totals = status.totals || {};
  56.         const running = Boolean(status.running);
  57.         const stateClass = status.state_class || "secondary";
  58.         if (stateNode) {
  59.           stateNode.textContent = status.state_label || "Ожидает";
  60.           setBadgeClass(stateClass);
  61.         }
  62.         if (spinner) spinner.classList.toggle("d-none", !running);
  63.         if (progress) {
  64.           progress.style.width = running || status.state === "completed" ? "100%" : "0%";
  65.           progress.classList.toggle("progress-bar-striped", running);
  66.           progress.classList.toggle("progress-bar-animated", running);
  67.         }
  68.         if (startButton) startButton.disabled = running;
  69.         if (stopButton) stopButton.disabled = !running;
  70.         setText("[data-steam-bg-message]", status.message || "Фоновое Steam-дозаполнение ещё не запускалось.");
  71.         setText("[data-steam-bg-pass]", status.pass || 0);
  72.         setText("[data-steam-bg-scanned]", totals.scanned_products || 0);
  73.         setText("[data-steam-bg-games]", totals.scanned_games || 0);
  74.         setText("[data-steam-bg-updated]", totals.updated_products || 0);
  75.         setText("[data-steam-bg-failed]", totals.failed_count || 0);
  76.         setText("[data-steam-bg-started]", status.started_at || "—");
  77.         setText("[data-steam-bg-updated-at]", status.updated_at || "—");
  78.         renderFailures(status.failed || []);
  79.         return running || status.state === "stopping";
  80.       };
  81.       const poll = async () => {
  82.         try {
  83.           const response = await fetch(statusUrl, {
  84.             headers: { "Accept": "application/json" },
  85.             credentials: "same-origin",
  86.           });
  87.           if (!response.ok) return;
  88.           const status = await response.json();
  89.           if (renderStatus(status)) {
  90.             window.setTimeout(poll, 5000);
  91.           }
  92.         } catch (error) {
  93.           window.setTimeout(poll, 10000);
  94.         }
  95.       };
  96.       poll();
  97.     })();
  98.   </script>
  99. {% endblock %}
  100. {% block content %}
  101.   {% set steamJob = steam_background_status|default({'state': 'idle', 'running': false, 'requested_stop': false, 'totals': {}, 'failed': []}) %}
  102.   <div class="row">
  103.     <div class="col-md-6 col-lg-4 col-xl">
  104.       <div class="card">
  105.         <div class="card-body">
  106.           <div class="widget-first">
  107.             <div class="d-flex align-items-center mb-2">
  108.               <div class="p-2 border border-primary border-opacity-10 bg-primary-subtle rounded-2 me-2">
  109.                 <div class="bg-primary rounded-circle widget-size text-center"><i class="mdi mdi-package-variant fs-20 text-white"></i></div>
  110.               </div>
  111.               <p class="mb-0 text-dark fs-15">Карточек каталога</p>
  112.             </div>
  113.             <div class="d-flex justify-content-between align-items-center">
  114.               <h3 class="mb-0 fs-22 text-dark me-3">{{ stats.catalog_products }}</h3>
  115.               <div class="text-center">
  116.                 <span class="text-primary fs-14">{{ stats.raw_products }}</span>
  117.                 <p class="text-dark fs-13 mb-0">raw-позиций</p>
  118.               </div>
  119.             </div>
  120.           </div>
  121.         </div>
  122.       </div>
  123.     </div>
  124.     <div class="col-md-6 col-lg-4 col-xl">
  125.       <div class="card">
  126.         <div class="card-body">
  127.           <div class="widget-first">
  128.             <div class="d-flex align-items-center mb-2">
  129.               <div class="p-2 border border-success border-opacity-10 bg-success-subtle rounded-2 me-2">
  130.                 <div class="bg-success rounded-circle widget-size text-center"><i class="mdi mdi-check-circle-outline fs-20 text-white"></i></div>
  131.               </div>
  132.               <p class="mb-0 text-dark fs-15">Активны</p>
  133.             </div>
  134.             <div class="d-flex justify-content-between align-items-center">
  135.               <h3 class="mb-0 fs-22 text-dark me-3">{{ stats.active_products }}</h3>
  136.               <div class="text-center">
  137.                 <span class="text-success fs-14">{{ stats.in_stock }}</span>
  138.                 <p class="text-dark fs-13 mb-0">в наличии</p>
  139.               </div>
  140.             </div>
  141.           </div>
  142.         </div>
  143.       </div>
  144.     </div>
  145.     <div class="col-md-6 col-lg-4 col-xl">
  146.       <div class="card">
  147.         <div class="card-body">
  148.           <div class="widget-first">
  149.             <div class="d-flex align-items-center mb-2">
  150.               <div class="p-2 border border-warning border-opacity-10 bg-warning-subtle rounded-2 me-2">
  151.                 <div class="bg-warning rounded-circle widget-size text-center"><i class="mdi mdi-percent-outline fs-20 text-white"></i></div>
  152.               </div>
  153.               <p class="mb-0 text-dark fs-15">Со скидкой</p>
  154.             </div>
  155.             <div class="d-flex justify-content-between align-items-center">
  156.               <h3 class="mb-0 fs-22 text-dark me-3">{{ stats.discounted_products }}</h3>
  157.               <div class="text-center">
  158.                 <span class="text-muted fs-14">0%</span>
  159.                 <p class="text-dark fs-13 mb-0">по умолчанию</p>
  160.               </div>
  161.             </div>
  162.           </div>
  163.         </div>
  164.       </div>
  165.     </div>
  166.     <div class="col-md-6 col-lg-4 col-xl">
  167.       <div class="card">
  168.         <div class="card-body">
  169.           <div class="widget-first">
  170.             <div class="d-flex align-items-center mb-2">
  171.               <div class="p-2 border border-info border-opacity-10 bg-info-subtle rounded-2 me-2">
  172.                 <div class="bg-info rounded-circle widget-size text-center"><i class="mdi mdi-database-outline fs-20 text-white"></i></div>
  173.               </div>
  174.               <p class="mb-0 text-dark fs-15">Источники</p>
  175.             </div>
  176.             <div class="d-flex justify-content-between align-items-center">
  177.               <h3 class="mb-0 fs-22 text-dark me-3">{{ stats.sources }}</h3>
  178.               <div class="text-center">
  179.                 <span class="text-primary fs-14">{{ stats.categories }}</span>
  180.                 <p class="text-dark fs-13 mb-0">категорий</p>
  181.               </div>
  182.             </div>
  183.           </div>
  184.         </div>
  185.       </div>
  186.     </div>
  187.     <div class="col-md-6 col-lg-4 col-xl">
  188.       <div class="card">
  189.         <div class="card-body">
  190.           <div class="widget-first">
  191.             <div class="d-flex align-items-center mb-2">
  192.               <div class="p-2 border border-secondary border-opacity-10 bg-secondary-subtle rounded-2 me-2">
  193.                 <div class="bg-secondary rounded-circle widget-size text-center"><i class="mdi mdi-account-group-outline fs-20 text-white"></i></div>
  194.               </div>
  195.               <p class="mb-0 text-dark fs-15">Пользователи</p>
  196.             </div>
  197.             <div class="d-flex justify-content-between align-items-center">
  198.               <h3 class="mb-0 fs-22 text-dark me-3">{{ stats.users }}</h3>
  199.               <div class="text-center">
  200.                 <span class="text-muted fs-14">{{ stats.manual_products }}</span>
  201.                 <p class="text-dark fs-13 mb-0">manual</p>
  202.               </div>
  203.             </div>
  204.           </div>
  205.         </div>
  206.       </div>
  207.     </div>
  208.   </div>
  209.   <div class="row">
  210.     <div class="col-xl-5">
  211.       <div class="card">
  212.         <div class="card-body">
  213.           <div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
  214.             <div>
  215.               <p class="text-muted mb-1">GalaxyLink</p>
  216.               <h5 class="mb-0">Синхронизация каталога</h5>
  217.             </div>
  218.             <div class="d-flex flex-wrap gap-2">
  219.               <form method="post" action="{{ path('admin_sync_galaxylink') }}" data-admin-loading-form data-loading-title="Обновляем GalaxyLink" data-loading-text="Загружаем цены, наличие, регионы и варианты товаров. Это может занять несколько минут.">
  220.                 <button class="btn btn-primary" type="submit">
  221.                   <i class="mdi mdi-database-sync-outline me-1"></i>Обновить GalaxyLink
  222.                 </button>
  223.               </form>
  224.               <form method="post" action="{{ path('admin_sync_steam_content') }}" data-admin-loading-form data-loading-title="Дозаполняем из Steam" data-loading-text="Ищем недостающие описания, картинки и метаданные. За один запуск обрабатывается безопасная порция.">
  225.                 <button class="btn btn-outline-primary" type="submit">
  226.                   <i class="mdi mdi-steam me-1"></i>Дозаполнить из Steam
  227.                 </button>
  228.               </form>
  229.               <form method="post" action="{{ path('admin_sync_steam_background_start') }}">
  230.                 <button class="btn btn-outline-success" type="submit" data-steam-bg-start-button {{ steamJob.running ? 'disabled' : '' }}>
  231.                   <i class="mdi mdi-play-circle-outline me-1"></i>Фоном до остановки
  232.                 </button>
  233.               </form>
  234.               <form method="post" action="{{ path('admin_sync_steam_background_stop') }}">
  235.                 <button class="btn btn-outline-danger" type="submit" data-steam-bg-stop-button {{ steamJob.running ? '' : 'disabled' }}>
  236.                   <i class="mdi mdi-stop-circle-outline me-1"></i>Остановить
  237.                 </button>
  238.               </form>
  239.             </div>
  240.           </div>
  241.           <div class="row g-2">
  242.             <div class="col">
  243.               <div class="border rounded-2 p-3">
  244.                 <h4 class="mb-1">{{ stats.delivery.gifts }}</h4>
  245.                 <p class="text-muted mb-0">Steam Gifts</p>
  246.               </div>
  247.             </div>
  248.             <div class="col">
  249.               <div class="border rounded-2 p-3">
  250.                 <h4 class="mb-1">{{ stats.delivery.keys }}</h4>
  251.                 <p class="text-muted mb-0">Steam Keys</p>
  252.               </div>
  253.             </div>
  254.             <div class="col">
  255.               <div class="border rounded-2 p-3">
  256.                 <h4 class="mb-1">{{ stats.delivery.other }}</h4>
  257.                 <p class="text-muted mb-0">Другое</p>
  258.               </div>
  259.             </div>
  260.           </div>
  261.           <p class="text-muted mt-3 mb-0">
  262.             GalaxyLink обновляет цены, наличие, регионы и варианты. Разовое Steam-дозаполнение обрабатывает до 300 raw-товаров. Фоновый режим сам запускает следующие порции, пока не закончится каталог или пока ты его не остановишь.
  263.           </p>
  264.           <div
  265.             class="border rounded-2 p-3 mt-3"
  266.             data-steam-background-panel
  267.             data-status-url="{{ path('admin_sync_steam_background_status') }}"
  268.             data-running="{{ steamJob.running ? '1' : '0' }}"
  269.           >
  270.             <div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
  271.               <div>
  272.                 <div class="d-flex align-items-center gap-2 mb-1">
  273.                   <span class="spinner-border spinner-border-sm text-primary {{ steamJob.running ? '' : 'd-none' }}" role="status" aria-hidden="true" data-steam-bg-spinner></span>
  274.                   <strong>Фоновое Steam-дозаполнение</strong>
  275.                   <span class="badge bg-{{ steamJob.state_class|default('light') }}-subtle text-{{ steamJob.state_class|default('muted') }}" data-steam-bg-state>{{ steamJob.state_label|default('Ожидает') }}</span>
  276.                 </div>
  277.                 <p class="text-muted mb-0" data-steam-bg-message>{{ steamJob.message|default('Фоновое Steam-дозаполнение ещё не запускалось.') }}</p>
  278.               </div>
  279.               <small class="text-muted">
  280.                 Порция <span data-steam-bg-pass>{{ steamJob.pass|default(0) }}</span>
  281.               </small>
  282.             </div>
  283.             <div class="progress mt-3" style="height: 6px;">
  284.               <div class="progress-bar {{ steamJob.running ? 'progress-bar-striped progress-bar-animated' : '' }}" style="width: {{ steamJob.running ? '100' : '0' }}%;" data-steam-bg-progress></div>
  285.             </div>
  286.             <div class="row g-2 mt-2">
  287.               <div class="col-6 col-md-3">
  288.                 <small class="text-muted d-block">Проверено</small>
  289.                 <strong data-steam-bg-scanned>{{ steamJob.totals.scanned_products|default(0) }}</strong>
  290.               </div>
  291.               <div class="col-6 col-md-3">
  292.                 <small class="text-muted d-block">Групп</small>
  293.                 <strong data-steam-bg-games>{{ steamJob.totals.scanned_games|default(0) }}</strong>
  294.               </div>
  295.               <div class="col-6 col-md-3">
  296.                 <small class="text-muted d-block">Обновлено</small>
  297.                 <strong class="text-success" data-steam-bg-updated>{{ steamJob.totals.updated_products|default(0) }}</strong>
  298.               </div>
  299.               <div class="col-6 col-md-3">
  300.                 <small class="text-muted d-block">Ошибок</small>
  301.                 <strong class="text-warning" data-steam-bg-failed>{{ steamJob.totals.failed_count|default(0) }}</strong>
  302.               </div>
  303.             </div>
  304.             <div class="d-flex justify-content-between flex-wrap gap-2 mt-2">
  305.               <small class="text-muted">Старт: <span data-steam-bg-started>{{ steamJob.started_at|default('—') ?: '—' }}</span></small>
  306.               <small class="text-muted">Обновлено: <span data-steam-bg-updated-at>{{ steamJob.updated_at|default('—') ?: '—' }}</span></small>
  307.             </div>
  308.             <div class="mt-3 {{ steamJob.failed|default([]) ? '' : 'd-none' }}" data-steam-bg-failed-wrap>
  309.               <details>
  310.                 <summary class="fw-semibold">Последние ошибки фонового запуска</summary>
  311.                 <div class="table-responsive mt-2" style="max-height: 220px;">
  312.                   <table class="table table-sm table-borderless mb-0">
  313.                     <tbody data-steam-bg-failed-list>
  314.                       {% for failed in steamJob.failed|default([]) %}
  315.                         <tr>
  316.                           <td class="text-nowrap">{% if failed.id|default(0) %}#{{ failed.id }}{% else %}—{% endif %}</td>
  317.                           <td>{{ failed.name|default('Товар') }}</td>
  318.                           <td class="text-muted">{{ failed.reason|default('Ошибка Steam') }}</td>
  319.                         </tr>
  320.                       {% endfor %}
  321.                     </tbody>
  322.                   </table>
  323.                 </div>
  324.               </details>
  325.             </div>
  326.           </div>
  327.           {% if steam_enrichment_report %}
  328.             <div class="alert alert-info mt-3 mb-0">
  329.               <div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
  330.                 <div>
  331.                   <strong>Отчёт Steam-дозаполнения</strong>
  332.                   <p class="mb-0">
  333.                     Проверено {{ steam_enrichment_report.scanned_products }} товаров / {{ steam_enrichment_report.scanned_games }} игровых групп.
  334.                     Обновлено {{ steam_enrichment_report.updated_products }} товаров.
  335.                     Не получилось {{ steam_enrichment_report.failed_count }}.
  336.                   </p>
  337.                 </div>
  338.               </div>
  339.               {% if steam_enrichment_report.failed|default([]) %}
  340.                 <details class="mt-2" open>
  341.                   <summary class="fw-semibold">Список товаров, которые не получилось обновить</summary>
  342.                   <div class="table-responsive mt-2" style="max-height: 320px;">
  343.                     <table class="table table-sm table-borderless mb-0">
  344.                       <tbody>
  345.                         {% for failed in steam_enrichment_report.failed %}
  346.                           <tr>
  347.                             <td class="text-nowrap">
  348.                               {% if failed.id %}
  349.                                 <a href="{{ path('admin_product_edit', {'id': failed.id}) }}">#{{ failed.id }}</a>
  350.                               {% else %}
  351.                                 —
  352.                               {% endif %}
  353.                             </td>
  354.                             <td>{{ failed.name }}</td>
  355.                             <td class="text-muted">{{ failed.reason }}</td>
  356.                           </tr>
  357.                         {% endfor %}
  358.                       </tbody>
  359.                     </table>
  360.                   </div>
  361.                   {% if steam_enrichment_report.failed_truncated|default(0) > 0 %}
  362.                     <p class="text-muted mt-2 mb-0">Ещё {{ steam_enrichment_report.failed_truncated }} ошибок скрыто, чтобы не перегружать страницу.</p>
  363.                   {% endif %}
  364.                 </details>
  365.               {% endif %}
  366.             </div>
  367.           {% endif %}
  368.         </div>
  369.       </div>
  370.     </div>
  371.     <div class="col-xl-7">
  372.       <div class="card">
  373.         <div class="card-body">
  374.           <div class="d-flex justify-content-between align-items-center mb-3">
  375.             <div>
  376.               <p class="text-muted mb-1">Инвентарь</p>
  377.               <h5 class="mb-0">Сводка по источникам</h5>
  378.             </div>
  379.             <a class="btn btn-light" href="{{ path('admin_references') }}">Справочники</a>
  380.           </div>
  381.           <div class="table-responsive">
  382.             <table class="table table-hover mb-0">
  383.               <thead>
  384.                 <tr>
  385.                   <th>Источник</th>
  386.                   <th>Provider</th>
  387.                   <th>Товары</th>
  388.                   <th>Синхронизация</th>
  389.                 </tr>
  390.               </thead>
  391.               <tbody>
  392.                 {% for source in sources %}
  393.                   <tr>
  394.                     <td>{{ source.name }}</td>
  395.                     <td><span class="badge bg-primary-subtle text-primary">{{ source.provider }}</span></td>
  396.                     <td>{{ source.products_count }}</td>
  397.                     <td class="text-muted">{{ source.last_synced_at ?: 'ещё не синхронизирован' }}</td>
  398.                   </tr>
  399.                 {% else %}
  400.                   <tr><td colspan="4" class="text-center text-muted py-4">Источников пока нет.</td></tr>
  401.                 {% endfor %}
  402.               </tbody>
  403.             </table>
  404.           </div>
  405.         </div>
  406.       </div>
  407.     </div>
  408.   </div>
  409.   <div class="row" id="products">
  410.     <div class="col-12">
  411.       <div class="card">
  412.         <div class="card-body">
  413.           <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
  414.             <div>
  415.               <p class="text-muted mb-1">Управление</p>
  416.               <h5 class="mb-0">Товары</h5>
  417.             </div>
  418.             <a class="btn btn-primary" href="{{ path('admin_product_new') }}"><i class="mdi mdi-plus me-1"></i>Добавить вручную</a>
  419.           </div>
  420.           <form class="row g-2 align-items-end mb-3" method="get" action="{{ path('admin_dashboard') }}">
  421.             <div class="col-xl-3 col-lg-4">
  422.               <label class="form-label">Поиск</label>
  423.               <input class="form-control" type="search" name="q" value="{{ filters.q }}" placeholder="Название, appid, код" />
  424.             </div>
  425.             <div class="col-xl-2 col-lg-4">
  426.               <label class="form-label">Источник</label>
  427.               <select class="form-select" name="source">
  428.                 <option value="">Все</option>
  429.                 {% for source in sources %}
  430.                   <option value="{{ source.id }}" {{ filters.source == source.id ? 'selected' : '' }}>{{ source.name }}</option>
  431.                 {% endfor %}
  432.               </select>
  433.             </div>
  434.             <div class="col-xl-2 col-lg-4">
  435.               <label class="form-label">Категория</label>
  436.               <select class="form-select" name="category">
  437.                 <option value="">Все</option>
  438.                 {% for category in categories %}
  439.                   <option value="{{ category.id }}" {{ filters.category == category.id ? 'selected' : '' }}>{{ category.name }}</option>
  440.                 {% endfor %}
  441.               </select>
  442.             </div>
  443.             <div class="col-xl-1 col-lg-4">
  444.               <label class="form-label">Видимость</label>
  445.               <select class="form-select" name="active">
  446.                 <option value="">Все</option>
  447.                 <option value="1" {{ filters.active == '1' ? 'selected' : '' }}>Да</option>
  448.                 <option value="0" {{ filters.active == '0' ? 'selected' : '' }}>Нет</option>
  449.               </select>
  450.             </div>
  451.             <div class="col-xl-1 col-lg-4">
  452.               <label class="form-label">Наличие</label>
  453.               <select class="form-select" name="stock">
  454.                 <option value="">Все</option>
  455.                 <option value="1" {{ filters.stock == '1' ? 'selected' : '' }}>Да</option>
  456.                 <option value="0" {{ filters.stock == '0' ? 'selected' : '' }}>Нет</option>
  457.               </select>
  458.             </div>
  459.             <div class="col-xl-1 col-lg-4">
  460.               <label class="form-label">Скидка</label>
  461.               <select class="form-select" name="discount">
  462.                 <option value="">Все</option>
  463.                 <option value="yes" {{ filters.discount == 'yes' ? 'selected' : '' }}>Есть</option>
  464.                 <option value="no" {{ filters.discount == 'no' ? 'selected' : '' }}>Нет</option>
  465.               </select>
  466.             </div>
  467.             <div class="col-xl-2 col-lg-4 d-flex gap-2">
  468.               <button class="btn btn-primary flex-grow-1" type="submit">Показать</button>
  469.               <a class="btn btn-light" href="{{ path('admin_dashboard') }}">Сброс</a>
  470.             </div>
  471.           </form>
  472.           <form id="bulkProductsForm" class="d-flex align-items-center gap-2 flex-wrap mb-3" method="post" action="{{ path('admin_products_bulk', filters) }}">
  473.             <select class="form-select" name="action" style="max-width: 230px;">
  474.               <option value="">Массовое действие</option>
  475.               <option value="activate">Включить</option>
  476.               <option value="deactivate">Скрыть</option>
  477.               <option value="stock_on">Поставить в наличие</option>
  478.               <option value="stock_off">Убрать из наличия</option>
  479.               <option value="set_discount">Поставить скидку</option>
  480.               <option value="clear_discount">Очистить скидку</option>
  481.             </select>
  482.             <input class="form-control" name="discount_percent" type="number" min="0" max="100" step="0.01" placeholder="%" style="max-width: 95px;" />
  483.             <button class="btn btn-dark" type="submit">Выполнить</button>
  484.             <span class="text-muted ms-auto">{{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }}</span>
  485.           </form>
  486.           <div class="table-responsive">
  487.             <table class="table table-hover align-middle mb-0">
  488.               <thead>
  489.                 <tr>
  490.                   <th style="width: 38px;"><input class="form-check-input" type="checkbox" data-check-all=".admin-product-check" /></th>
  491.                   <th>Товар</th>
  492.                   <th>Источник</th>
  493.                   <th>Выдача</th>
  494.                   <th>Цена</th>
  495.                   <th>Скидка</th>
  496.                   <th>Статус</th>
  497.                   <th>Медиа</th>
  498.                   <th class="text-end">Действие</th>
  499.                 </tr>
  500.               </thead>
  501.               <tbody>
  502.                 {% for product in products %}
  503.                   <tr>
  504.                     <td><input class="form-check-input admin-product-check" form="bulkProductsForm" type="checkbox" name="ids[]" value="{{ product.id }}" /></td>
  505.                     <td>
  506.                       <div class="d-flex align-items-center gap-2">
  507.                         <img class="admin-table-thumb" src="{{ product.image_url ?: '/assets/product-pubg.png' }}" alt="" />
  508.                         <div>
  509.                           <h6 class="mb-0 text-truncate admin-product-name">{{ product.name }}</h6>
  510.                           <small class="text-muted">{{ product.external_code ?: 'manual' }}{% if product.appid %} · appid {{ product.appid }}{% endif %}</small>
  511.                         </div>
  512.                       </div>
  513.                     </td>
  514.                     <td>
  515.                       <span class="badge bg-primary-subtle text-primary">{{ product.source_name ?: product.source_code ?: product.provider }}</span>
  516.                       <div class="text-muted fs-12 mt-1">{{ product.category_name ?: product.provider_category_code }}</div>
  517.                     </td>
  518.                     <td>{{ product.delivery_label }}</td>
  519.                     <td><strong>{{ product.price_label }}</strong></td>
  520.                     <td>
  521.                       {% if product.discount_label %}
  522.                         <span class="badge bg-success-subtle text-success">{{ product.discount_label }}</span>
  523.                       {% else %}
  524.                         <span class="text-muted">нет</span>
  525.                       {% endif %}
  526.                     </td>
  527.                     <td>
  528.                       <div class="d-flex gap-1 flex-wrap">
  529.                         <form method="post" action="{{ path('admin_product_toggle', {'id': product.id}) }}">
  530.                           <input type="hidden" name="field" value="is_active" />
  531.                           <button class="badge border-0 {{ product.is_active ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger' }}" type="submit">{{ product.is_active ? 'активен' : 'скрыт' }}</button>
  532.                         </form>
  533.                         <form method="post" action="{{ path('admin_product_toggle', {'id': product.id}) }}">
  534.                           <input type="hidden" name="field" value="in_stock" />
  535.                           <button class="badge border-0 {{ product.in_stock ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger' }}" type="submit">{{ product.in_stock ? 'в наличии' : 'нет' }}</button>
  536.                         </form>
  537.                       </div>
  538.                     </td>
  539.                     <td>
  540.                       <small>{{ product.gallery_count }} фото</small><br />
  541.                       <small>{{ product.forms_count }} форм</small>
  542.                     </td>
  543.                     <td class="text-end">
  544.                       <a class="btn btn-sm btn-light me-1" href="{{ product.product_url }}" target="_blank" rel="noreferrer"><i class="mdi mdi-eye-outline"></i></a>
  545.                       <a class="btn btn-sm bg-primary-subtle" href="{{ path('admin_product_edit', {'id': product.id}) }}"><i class="mdi mdi-pencil-outline text-primary"></i></a>
  546.                     </td>
  547.                   </tr>
  548.                 {% else %}
  549.                   <tr><td colspan="9" class="text-center text-muted py-4">По фильтрам ничего не найдено.</td></tr>
  550.                 {% endfor %}
  551.               </tbody>
  552.             </table>
  553.           </div>
  554.           {% if pagination.total_pages > 1 %}
  555.             <div class="d-flex justify-content-end align-items-center gap-2 mt-3">
  556.               {% if pagination.page > 1 %}
  557.                 <a class="btn btn-light" href="{{ path('admin_dashboard', filters|merge({'page': pagination.page - 1})) }}#products">Назад</a>
  558.               {% endif %}
  559.               <span class="text-muted">Страница {{ pagination.page }} из {{ pagination.total_pages }}</span>
  560.               {% if pagination.page < pagination.total_pages %}
  561.                 <a class="btn btn-light" href="{{ path('admin_dashboard', filters|merge({'page': pagination.page + 1})) }}#products">Дальше</a>
  562.               {% endif %}
  563.             </div>
  564.           {% endif %}
  565.         </div>
  566.       </div>
  567.     </div>
  568.   </div>
  569. {% endblock %}