{% extends 'admin/base.html.twig' %}
{% block title %}Главная{% endblock %}
{% block heading %}Главная{% endblock %}
{% block page_actions %}
<div class="text-sm-end">
<a class="btn btn-primary" href="{{ path('admin_product_new') }}">
<i class="mdi mdi-plus me-1"></i>Добавить товар
</a>
</div>
{% endblock %}
{% block scripts %}
<script>
(() => {
const panel = document.querySelector("[data-steam-background-panel]");
if (!panel) return;
const statusUrl = panel.dataset.statusUrl;
const stateNode = panel.querySelector("[data-steam-bg-state]");
const spinner = panel.querySelector("[data-steam-bg-spinner]");
const progress = panel.querySelector("[data-steam-bg-progress]");
const failedWrap = panel.querySelector("[data-steam-bg-failed-wrap]");
const failedList = panel.querySelector("[data-steam-bg-failed-list]");
const startButton = document.querySelector("[data-steam-bg-start-button]");
const stopButton = document.querySelector("[data-steam-bg-stop-button]");
const badgeClasses = ["primary", "success", "warning", "danger", "secondary", "info", "light", "dark"];
const setText = (selector, value) => {
const node = panel.querySelector(selector);
if (node) node.textContent = value ?? "—";
};
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
const setBadgeClass = (kind) => {
if (!stateNode) return;
badgeClasses.forEach((item) => {
stateNode.classList.remove(`bg-${item}-subtle`, `text-${item}`);
});
stateNode.classList.add(`bg-${kind}-subtle`, `text-${kind}`);
};
const renderFailures = (failures) => {
if (!failedWrap || !failedList) return;
const list = Array.isArray(failures) ? failures : [];
failedWrap.classList.toggle("d-none", list.length === 0);
failedList.innerHTML = list.slice(0, 20).map((failure) => `
<tr>
<td class="text-nowrap">${failure.id ? `#${escapeHtml(failure.id)}` : "—"}</td>
<td>${escapeHtml(failure.name || "Товар")}</td>
<td class="text-muted">${escapeHtml(failure.reason || "Ошибка Steam")}</td>
</tr>
`).join("");
};
const renderStatus = (status) => {
const totals = status.totals || {};
const running = Boolean(status.running);
const stateClass = status.state_class || "secondary";
if (stateNode) {
stateNode.textContent = status.state_label || "Ожидает";
setBadgeClass(stateClass);
}
if (spinner) spinner.classList.toggle("d-none", !running);
if (progress) {
progress.style.width = running || status.state === "completed" ? "100%" : "0%";
progress.classList.toggle("progress-bar-striped", running);
progress.classList.toggle("progress-bar-animated", running);
}
if (startButton) startButton.disabled = running;
if (stopButton) stopButton.disabled = !running;
setText("[data-steam-bg-message]", status.message || "Фоновое Steam-дозаполнение ещё не запускалось.");
setText("[data-steam-bg-pass]", status.pass || 0);
setText("[data-steam-bg-scanned]", totals.scanned_products || 0);
setText("[data-steam-bg-games]", totals.scanned_games || 0);
setText("[data-steam-bg-updated]", totals.updated_products || 0);
setText("[data-steam-bg-failed]", totals.failed_count || 0);
setText("[data-steam-bg-started]", status.started_at || "—");
setText("[data-steam-bg-updated-at]", status.updated_at || "—");
renderFailures(status.failed || []);
return running || status.state === "stopping";
};
const poll = async () => {
try {
const response = await fetch(statusUrl, {
headers: { "Accept": "application/json" },
credentials: "same-origin",
});
if (!response.ok) return;
const status = await response.json();
if (renderStatus(status)) {
window.setTimeout(poll, 5000);
}
} catch (error) {
window.setTimeout(poll, 10000);
}
};
poll();
})();
</script>
{% endblock %}
{% block content %}
{% set steamJob = steam_background_status|default({'state': 'idle', 'running': false, 'requested_stop': false, 'totals': {}, 'failed': []}) %}
<div class="row">
<div class="col-md-6 col-lg-4 col-xl">
<div class="card">
<div class="card-body">
<div class="widget-first">
<div class="d-flex align-items-center mb-2">
<div class="p-2 border border-primary border-opacity-10 bg-primary-subtle rounded-2 me-2">
<div class="bg-primary rounded-circle widget-size text-center"><i class="mdi mdi-package-variant fs-20 text-white"></i></div>
</div>
<p class="mb-0 text-dark fs-15">Карточек каталога</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-22 text-dark me-3">{{ stats.catalog_products }}</h3>
<div class="text-center">
<span class="text-primary fs-14">{{ stats.raw_products }}</span>
<p class="text-dark fs-13 mb-0">raw-позиций</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 col-xl">
<div class="card">
<div class="card-body">
<div class="widget-first">
<div class="d-flex align-items-center mb-2">
<div class="p-2 border border-success border-opacity-10 bg-success-subtle rounded-2 me-2">
<div class="bg-success rounded-circle widget-size text-center"><i class="mdi mdi-check-circle-outline fs-20 text-white"></i></div>
</div>
<p class="mb-0 text-dark fs-15">Активны</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-22 text-dark me-3">{{ stats.active_products }}</h3>
<div class="text-center">
<span class="text-success fs-14">{{ stats.in_stock }}</span>
<p class="text-dark fs-13 mb-0">в наличии</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 col-xl">
<div class="card">
<div class="card-body">
<div class="widget-first">
<div class="d-flex align-items-center mb-2">
<div class="p-2 border border-warning border-opacity-10 bg-warning-subtle rounded-2 me-2">
<div class="bg-warning rounded-circle widget-size text-center"><i class="mdi mdi-percent-outline fs-20 text-white"></i></div>
</div>
<p class="mb-0 text-dark fs-15">Со скидкой</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-22 text-dark me-3">{{ stats.discounted_products }}</h3>
<div class="text-center">
<span class="text-muted fs-14">0%</span>
<p class="text-dark fs-13 mb-0">по умолчанию</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 col-xl">
<div class="card">
<div class="card-body">
<div class="widget-first">
<div class="d-flex align-items-center mb-2">
<div class="p-2 border border-info border-opacity-10 bg-info-subtle rounded-2 me-2">
<div class="bg-info rounded-circle widget-size text-center"><i class="mdi mdi-database-outline fs-20 text-white"></i></div>
</div>
<p class="mb-0 text-dark fs-15">Источники</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-22 text-dark me-3">{{ stats.sources }}</h3>
<div class="text-center">
<span class="text-primary fs-14">{{ stats.categories }}</span>
<p class="text-dark fs-13 mb-0">категорий</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4 col-xl">
<div class="card">
<div class="card-body">
<div class="widget-first">
<div class="d-flex align-items-center mb-2">
<div class="p-2 border border-secondary border-opacity-10 bg-secondary-subtle rounded-2 me-2">
<div class="bg-secondary rounded-circle widget-size text-center"><i class="mdi mdi-account-group-outline fs-20 text-white"></i></div>
</div>
<p class="mb-0 text-dark fs-15">Пользователи</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<h3 class="mb-0 fs-22 text-dark me-3">{{ stats.users }}</h3>
<div class="text-center">
<span class="text-muted fs-14">{{ stats.manual_products }}</span>
<p class="text-dark fs-13 mb-0">manual</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xl-5">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<p class="text-muted mb-1">GalaxyLink</p>
<h5 class="mb-0">Синхронизация каталога</h5>
</div>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ path('admin_sync_galaxylink') }}" data-admin-loading-form data-loading-title="Обновляем GalaxyLink" data-loading-text="Загружаем цены, наличие, регионы и варианты товаров. Это может занять несколько минут.">
<button class="btn btn-primary" type="submit">
<i class="mdi mdi-database-sync-outline me-1"></i>Обновить GalaxyLink
</button>
</form>
<form method="post" action="{{ path('admin_sync_steam_content') }}" data-admin-loading-form data-loading-title="Дозаполняем из Steam" data-loading-text="Ищем недостающие описания, картинки и метаданные. За один запуск обрабатывается безопасная порция.">
<button class="btn btn-outline-primary" type="submit">
<i class="mdi mdi-steam me-1"></i>Дозаполнить из Steam
</button>
</form>
<form method="post" action="{{ path('admin_sync_steam_background_start') }}">
<button class="btn btn-outline-success" type="submit" data-steam-bg-start-button {{ steamJob.running ? 'disabled' : '' }}>
<i class="mdi mdi-play-circle-outline me-1"></i>Фоном до остановки
</button>
</form>
<form method="post" action="{{ path('admin_sync_steam_background_stop') }}">
<button class="btn btn-outline-danger" type="submit" data-steam-bg-stop-button {{ steamJob.running ? '' : 'disabled' }}>
<i class="mdi mdi-stop-circle-outline me-1"></i>Остановить
</button>
</form>
</div>
</div>
<div class="row g-2">
<div class="col">
<div class="border rounded-2 p-3">
<h4 class="mb-1">{{ stats.delivery.gifts }}</h4>
<p class="text-muted mb-0">Steam Gifts</p>
</div>
</div>
<div class="col">
<div class="border rounded-2 p-3">
<h4 class="mb-1">{{ stats.delivery.keys }}</h4>
<p class="text-muted mb-0">Steam Keys</p>
</div>
</div>
<div class="col">
<div class="border rounded-2 p-3">
<h4 class="mb-1">{{ stats.delivery.other }}</h4>
<p class="text-muted mb-0">Другое</p>
</div>
</div>
</div>
<p class="text-muted mt-3 mb-0">
GalaxyLink обновляет цены, наличие, регионы и варианты. Разовое Steam-дозаполнение обрабатывает до 300 raw-товаров. Фоновый режим сам запускает следующие порции, пока не закончится каталог или пока ты его не остановишь.
</p>
<div
class="border rounded-2 p-3 mt-3"
data-steam-background-panel
data-status-url="{{ path('admin_sync_steam_background_status') }}"
data-running="{{ steamJob.running ? '1' : '0' }}"
>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm text-primary {{ steamJob.running ? '' : 'd-none' }}" role="status" aria-hidden="true" data-steam-bg-spinner></span>
<strong>Фоновое Steam-дозаполнение</strong>
<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>
</div>
<p class="text-muted mb-0" data-steam-bg-message>{{ steamJob.message|default('Фоновое Steam-дозаполнение ещё не запускалось.') }}</p>
</div>
<small class="text-muted">
Порция <span data-steam-bg-pass>{{ steamJob.pass|default(0) }}</span>
</small>
</div>
<div class="progress mt-3" style="height: 6px;">
<div class="progress-bar {{ steamJob.running ? 'progress-bar-striped progress-bar-animated' : '' }}" style="width: {{ steamJob.running ? '100' : '0' }}%;" data-steam-bg-progress></div>
</div>
<div class="row g-2 mt-2">
<div class="col-6 col-md-3">
<small class="text-muted d-block">Проверено</small>
<strong data-steam-bg-scanned>{{ steamJob.totals.scanned_products|default(0) }}</strong>
</div>
<div class="col-6 col-md-3">
<small class="text-muted d-block">Групп</small>
<strong data-steam-bg-games>{{ steamJob.totals.scanned_games|default(0) }}</strong>
</div>
<div class="col-6 col-md-3">
<small class="text-muted d-block">Обновлено</small>
<strong class="text-success" data-steam-bg-updated>{{ steamJob.totals.updated_products|default(0) }}</strong>
</div>
<div class="col-6 col-md-3">
<small class="text-muted d-block">Ошибок</small>
<strong class="text-warning" data-steam-bg-failed>{{ steamJob.totals.failed_count|default(0) }}</strong>
</div>
</div>
<div class="d-flex justify-content-between flex-wrap gap-2 mt-2">
<small class="text-muted">Старт: <span data-steam-bg-started>{{ steamJob.started_at|default('—') ?: '—' }}</span></small>
<small class="text-muted">Обновлено: <span data-steam-bg-updated-at>{{ steamJob.updated_at|default('—') ?: '—' }}</span></small>
</div>
<div class="mt-3 {{ steamJob.failed|default([]) ? '' : 'd-none' }}" data-steam-bg-failed-wrap>
<details>
<summary class="fw-semibold">Последние ошибки фонового запуска</summary>
<div class="table-responsive mt-2" style="max-height: 220px;">
<table class="table table-sm table-borderless mb-0">
<tbody data-steam-bg-failed-list>
{% for failed in steamJob.failed|default([]) %}
<tr>
<td class="text-nowrap">{% if failed.id|default(0) %}#{{ failed.id }}{% else %}—{% endif %}</td>
<td>{{ failed.name|default('Товар') }}</td>
<td class="text-muted">{{ failed.reason|default('Ошибка Steam') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
</div>
</div>
{% if steam_enrichment_report %}
<div class="alert alert-info mt-3 mb-0">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<strong>Отчёт Steam-дозаполнения</strong>
<p class="mb-0">
Проверено {{ steam_enrichment_report.scanned_products }} товаров / {{ steam_enrichment_report.scanned_games }} игровых групп.
Обновлено {{ steam_enrichment_report.updated_products }} товаров.
Не получилось {{ steam_enrichment_report.failed_count }}.
</p>
</div>
</div>
{% if steam_enrichment_report.failed|default([]) %}
<details class="mt-2" open>
<summary class="fw-semibold">Список товаров, которые не получилось обновить</summary>
<div class="table-responsive mt-2" style="max-height: 320px;">
<table class="table table-sm table-borderless mb-0">
<tbody>
{% for failed in steam_enrichment_report.failed %}
<tr>
<td class="text-nowrap">
{% if failed.id %}
<a href="{{ path('admin_product_edit', {'id': failed.id}) }}">#{{ failed.id }}</a>
{% else %}
—
{% endif %}
</td>
<td>{{ failed.name }}</td>
<td class="text-muted">{{ failed.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if steam_enrichment_report.failed_truncated|default(0) > 0 %}
<p class="text-muted mt-2 mb-0">Ещё {{ steam_enrichment_report.failed_truncated }} ошибок скрыто, чтобы не перегружать страницу.</p>
{% endif %}
</details>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-xl-7">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted mb-1">Инвентарь</p>
<h5 class="mb-0">Сводка по источникам</h5>
</div>
<a class="btn btn-light" href="{{ path('admin_references') }}">Справочники</a>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Источник</th>
<th>Provider</th>
<th>Товары</th>
<th>Синхронизация</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
<tr>
<td>{{ source.name }}</td>
<td><span class="badge bg-primary-subtle text-primary">{{ source.provider }}</span></td>
<td>{{ source.products_count }}</td>
<td class="text-muted">{{ source.last_synced_at ?: 'ещё не синхронизирован' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-4">Источников пока нет.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row" id="products">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div>
<p class="text-muted mb-1">Управление</p>
<h5 class="mb-0">Товары</h5>
</div>
<a class="btn btn-primary" href="{{ path('admin_product_new') }}"><i class="mdi mdi-plus me-1"></i>Добавить вручную</a>
</div>
<form class="row g-2 align-items-end mb-3" method="get" action="{{ path('admin_dashboard') }}">
<div class="col-xl-3 col-lg-4">
<label class="form-label">Поиск</label>
<input class="form-control" type="search" name="q" value="{{ filters.q }}" placeholder="Название, appid, код" />
</div>
<div class="col-xl-2 col-lg-4">
<label class="form-label">Источник</label>
<select class="form-select" name="source">
<option value="">Все</option>
{% for source in sources %}
<option value="{{ source.id }}" {{ filters.source == source.id ? 'selected' : '' }}>{{ source.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-xl-2 col-lg-4">
<label class="form-label">Категория</label>
<select class="form-select" name="category">
<option value="">Все</option>
{% for category in categories %}
<option value="{{ category.id }}" {{ filters.category == category.id ? 'selected' : '' }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-xl-1 col-lg-4">
<label class="form-label">Видимость</label>
<select class="form-select" name="active">
<option value="">Все</option>
<option value="1" {{ filters.active == '1' ? 'selected' : '' }}>Да</option>
<option value="0" {{ filters.active == '0' ? 'selected' : '' }}>Нет</option>
</select>
</div>
<div class="col-xl-1 col-lg-4">
<label class="form-label">Наличие</label>
<select class="form-select" name="stock">
<option value="">Все</option>
<option value="1" {{ filters.stock == '1' ? 'selected' : '' }}>Да</option>
<option value="0" {{ filters.stock == '0' ? 'selected' : '' }}>Нет</option>
</select>
</div>
<div class="col-xl-1 col-lg-4">
<label class="form-label">Скидка</label>
<select class="form-select" name="discount">
<option value="">Все</option>
<option value="yes" {{ filters.discount == 'yes' ? 'selected' : '' }}>Есть</option>
<option value="no" {{ filters.discount == 'no' ? 'selected' : '' }}>Нет</option>
</select>
</div>
<div class="col-xl-2 col-lg-4 d-flex gap-2">
<button class="btn btn-primary flex-grow-1" type="submit">Показать</button>
<a class="btn btn-light" href="{{ path('admin_dashboard') }}">Сброс</a>
</div>
</form>
<form id="bulkProductsForm" class="d-flex align-items-center gap-2 flex-wrap mb-3" method="post" action="{{ path('admin_products_bulk', filters) }}">
<select class="form-select" name="action" style="max-width: 230px;">
<option value="">Массовое действие</option>
<option value="activate">Включить</option>
<option value="deactivate">Скрыть</option>
<option value="stock_on">Поставить в наличие</option>
<option value="stock_off">Убрать из наличия</option>
<option value="set_discount">Поставить скидку</option>
<option value="clear_discount">Очистить скидку</option>
</select>
<input class="form-control" name="discount_percent" type="number" min="0" max="100" step="0.01" placeholder="%" style="max-width: 95px;" />
<button class="btn btn-dark" type="submit">Выполнить</button>
<span class="text-muted ms-auto">{{ pagination.from }}-{{ pagination.to }} из {{ pagination.total }}</span>
</form>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th style="width: 38px;"><input class="form-check-input" type="checkbox" data-check-all=".admin-product-check" /></th>
<th>Товар</th>
<th>Источник</th>
<th>Выдача</th>
<th>Цена</th>
<th>Скидка</th>
<th>Статус</th>
<th>Медиа</th>
<th class="text-end">Действие</th>
</tr>
</thead>
<tbody>
{% for product in products %}
<tr>
<td><input class="form-check-input admin-product-check" form="bulkProductsForm" type="checkbox" name="ids[]" value="{{ product.id }}" /></td>
<td>
<div class="d-flex align-items-center gap-2">
<img class="admin-table-thumb" src="{{ product.image_url ?: '/assets/product-pubg.png' }}" alt="" />
<div>
<h6 class="mb-0 text-truncate admin-product-name">{{ product.name }}</h6>
<small class="text-muted">{{ product.external_code ?: 'manual' }}{% if product.appid %} · appid {{ product.appid }}{% endif %}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-primary-subtle text-primary">{{ product.source_name ?: product.source_code ?: product.provider }}</span>
<div class="text-muted fs-12 mt-1">{{ product.category_name ?: product.provider_category_code }}</div>
</td>
<td>{{ product.delivery_label }}</td>
<td><strong>{{ product.price_label }}</strong></td>
<td>
{% if product.discount_label %}
<span class="badge bg-success-subtle text-success">{{ product.discount_label }}</span>
{% else %}
<span class="text-muted">нет</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-1 flex-wrap">
<form method="post" action="{{ path('admin_product_toggle', {'id': product.id}) }}">
<input type="hidden" name="field" value="is_active" />
<button class="badge border-0 {{ product.is_active ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger' }}" type="submit">{{ product.is_active ? 'активен' : 'скрыт' }}</button>
</form>
<form method="post" action="{{ path('admin_product_toggle', {'id': product.id}) }}">
<input type="hidden" name="field" value="in_stock" />
<button class="badge border-0 {{ product.in_stock ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger' }}" type="submit">{{ product.in_stock ? 'в наличии' : 'нет' }}</button>
</form>
</div>
</td>
<td>
<small>{{ product.gallery_count }} фото</small><br />
<small>{{ product.forms_count }} форм</small>
</td>
<td class="text-end">
<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>
<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>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-muted py-4">По фильтрам ничего не найдено.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.total_pages > 1 %}
<div class="d-flex justify-content-end align-items-center gap-2 mt-3">
{% if pagination.page > 1 %}
<a class="btn btn-light" href="{{ path('admin_dashboard', filters|merge({'page': pagination.page - 1})) }}#products">Назад</a>
{% endif %}
<span class="text-muted">Страница {{ pagination.page }} из {{ pagination.total_pages }}</span>
{% if pagination.page < pagination.total_pages %}
<a class="btn btn-light" href="{{ path('admin_dashboard', filters|merge({'page': pagination.page + 1})) }}#products">Дальше</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}