Add monitoring PF service

This commit is contained in:
Grendgi
2026-06-04 14:55:41 +03:00
commit dd3edd7088
41 changed files with 3194 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Вход администратора — DLD Monitor{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">🔒 Режим администратора</h5>
<p class="text-muted small">
Введите PIN, чтобы разблокировать удаление проектов, конкурентов и
редактирование/удаление сотрудников. Сессия — на 8 часов.
</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="post" action="{{ url_path('/admin/login') }}">
<input type="hidden" name="next" value="{{ next }}">
<div class="mb-3">
<input type="password" name="pin" required autofocus class="form-control"
placeholder="PIN администратора">
</div>
<button class="btn btn-primary w-100">Войти</button>
</form>
<a href="{{ url_path(next) }}" class="btn btn-link btn-sm w-100 mt-2">← Отмена</a>
</div>
</div>
</div>
</div>
{% endblock %}

88
app/templates/base.html Normal file
View File

@@ -0,0 +1,88 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{% block title %}DLD Monitor{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f4f5f9; }
.navbar-brand { letter-spacing: .3px; }
.nav-link.active { font-weight: 600; color: #0d6efd !important; }
.badge-sale { background: #198754; }
.badge-rent { background: #0d6efd; }
.card { border: 1px solid #e9ecef; }
.listing-card { border-left: 4px solid #dee2e6; }
.listing-card.removed { border-left-color: #dc3545; opacity: 0.65; }
.listing-card.active { border-left-color: #198754; }
.src-pf { color: #d63384; font-weight: 600; }
.src-bayut { color: #0d6efd; font-weight: 600; }
.price-up { color: #dc3545; }
.price-down { color: #198754; }
pre.permit { display: inline; background: #eee; padding: 2px 6px; border-radius: 4px; }
.stat-chip { background:#fff; border:1px solid #e9ecef; border-radius:.5rem; padding:.4rem .8rem; }
.stat-chip .n { font-size:1.25rem; font-weight:700; line-height:1; }
footer { color:#9aa0a6; font-size:.8rem; }
/* Mobile tweaks */
@media (max-width: 575.98px) {
body { background: #fff; }
.container { padding-left: .75rem; padding-right: .75rem; }
h3 { font-size: 1.35rem; }
.card-body { padding: .75rem; }
.fs-4 { font-size: 1.1rem !important; }
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ url_path('/') }}">🏠 DLD Monitor</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#mainnav" aria-controls="mainnav"
aria-expanded="false" aria-label="Меню">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainnav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a href="{{ url_path('/') }}" class="nav-link {{ 'active' if request.url.path == '/' or request.url.path.startswith('/projects') else '' }}">Проекты</a>
</li>
<li class="nav-item">
<a href="{{ url_path('/employees') }}" class="nav-link {{ 'active' if request.url.path.startswith('/employees') else '' }}">Сотрудники</a>
</li>
</ul>
<div class="d-flex flex-column flex-lg-row align-items-stretch align-items-lg-center gap-2">
<a href="{{ url_path('/projects/new') }}" class="btn btn-sm btn-primary">+ Проект</a>
<span class="vr d-none d-lg-block"></span>
{% if request.state.is_admin %}
{% if request.state.admin_configured %}
<span class="badge bg-success align-self-start align-self-lg-center">🔓 админ</span>
<form method="post" action="{{ url_path('/admin/logout') }}">
<input type="hidden" name="next" value="{{ request.url.path }}">
<button class="btn btn-sm btn-outline-secondary w-100">Выйти</button>
</form>
{% else %}
<span class="badge bg-warning text-dark align-self-start align-self-lg-center" title="ADMIN_PIN не задан в .env — права не ограничены">⚠ без PIN</span>
{% endif %}
{% else %}
<a href="{{ url_path('/admin/login?next=' ~ request.url.path) }}" class="btn btn-sm btn-outline-dark">🔒 Админ</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container">
{% if flash %}
<div class="alert alert-info">{{ flash }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
<footer class="container text-center py-4">DLD Monitor · HOME LIGA REAL ESTATE</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Сотрудники — DLD Monitor{% endblock %}
{% block content %}
<h3>Сотрудники</h3>
<div class="alert alert-info">
<b>Как подключить Telegram:</b>
<ol class="mb-0">
<li>Сотрудник пишет боту в Telegram команду <code>/whoami</code> — бот вернёт его <code>chat_id</code>.</li>
<li>Вставьте этот <code>chat_id</code> в поле справа от имени и нажмите «Сохранить».</li>
</ol>
Если сотрудник пока не знает свой chat_id — пусть просто отправит <code>/start</code>, бот ответит и пришлёт id.
</div>
<form method="post" action="{{ url_path('/employees') }}" class="row g-2 mb-4 bg-white p-3 rounded shadow-sm">
<div class="col-md-5">
<input name="name" required class="form-control" placeholder="Имя сотрудника">
</div>
<div class="col-md-4">
<input name="tg_username" class="form-control" placeholder="@username (опционально)">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100">Добавить</button>
</div>
</form>
{% if not request.state.is_admin %}
<div class="alert alert-secondary py-2 small">
🔒 Редактирование и удаление сотрудников доступно только в режиме админа.
<a href="{{ url_path('/admin/login?next=/employees') }}">Войти</a>.
</div>
{% endif %}
<div class="table-responsive">
<table class="table bg-white rounded shadow-sm align-middle">
<thead class="table-light">
<tr><th>Имя</th><th>@username</th><th>Chat ID</th><th>Проектов</th>{% if request.state.is_admin %}<th style="width:1%"></th>{% endif %}</tr>
</thead>
<tbody>
{% for e in employees %}
{% if request.state.is_admin %}
<tr>
<form method="post" action="{{ url_path('/employees/' ~ e.id ~ '/update') }}">
<td>
<input name="name" value="{{ e.name }}" class="form-control form-control-sm" required>
</td>
<td>
<input name="tg_username" value="{{ e.tg_username or '' }}" class="form-control form-control-sm" placeholder="username">
</td>
<td>
<div class="input-group input-group-sm">
<input name="tg_chat_id" value="{{ e.tg_chat_id or '' }}" class="form-control"
placeholder="например 123456789">
{% if e.tg_chat_id %}
<span class="input-group-text text-success"></span>
{% endif %}
</div>
</td>
<td>{{ e.projects|length }}</td>
<td class="text-nowrap">
<button class="btn btn-sm btn-primary">Сохранить</button>
</form>
<form method="post" action="{{ url_path('/employees/' ~ e.id ~ '/delete') }}" class="d-inline"
onsubmit="return confirm('Удалить сотрудника?');">
<button class="btn btn-sm btn-outline-danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td>{{ e.name }}</td>
<td>{% if e.tg_username %}@{{ e.tg_username }}{% else %}—{% endif %}</td>
<td>
{% if e.tg_chat_id %}<code>{{ e.tg_chat_id }}</code> <span class="text-success"></span>
{% else %}<span class="text-muted">не задан</span>{% endif %}
</td>
<td>{{ e.projects|length }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}{{ project.title }} — DLD Monitor{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="mb-1">{{ project.title }}</h3>
<div class="text-muted">
{% if project.deal_type.value == 'sale' %}
<span class="badge badge-sale">Продажа</span>
{% else %}
<span class="badge badge-rent">Аренда</span>
{% endif %}
· Владелец: {{ project.owner.name }}
{% if project.last_checked_at %}
· Проверено: {{ project.last_checked_at | msk }} МСК
{% endif %}
</div>
<div class="small text-muted mt-1">
{% if project.building %}🏢 {{ project.building }}{% endif %}
{% if project.bedrooms is not none %} · 🛏️ {{ project.bedrooms }} BR{% endif %}
{% if project.size_sqft %} · 📐 {{ "{:,.0f}".format(project.size_sqft).replace(",", " ") }} sqft{% endif %}
{% if project.dld_permit %} · permit: <code>{{ project.dld_permit }}</code>{% endif %}
</div>
{% if project.our_price %}
<div class="mt-2">Наша цена: <b>{{ "{:,.0f}".format(project.our_price).replace(",", " ") }} AED</b></div>
{% endif %}
{% if project.our_url %}
<div class="small mt-1">Наше объявление: <a href="{{ project.our_url }}" target="_blank" rel="noopener">{{ project.our_url }}</a></div>
{% endif %}
{% if project.notes %}<div class="mt-2 text-muted"><i>{{ project.notes }}</i></div>{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0">
<form action="{{ url_path('/projects/' ~ project.id ~ '/check') }}" method="post">
<button class="btn btn-primary text-nowrap">Проверить сейчас</button>
</form>
{% if request.state.is_admin %}
<form action="{{ url_path('/projects/' ~ project.id ~ '/delete') }}" method="post"
onsubmit="return confirm('Удалить проект и всю историю?');">
<button class="btn btn-outline-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<div class="bg-white rounded shadow-sm p-3 mb-4">
<form method="post" action="{{ url_path('/projects/' ~ project.id ~ '/listings') }}" class="row g-2">
<div class="col-md-9">
<input name="url" type="url" required class="form-control"
placeholder="Вставьте URL объявления конкурента с propertyfinder.ae или bayut.com">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100">+ Добавить конкурента</button>
</div>
</form>
{% if project.our_url %}
<div class="mt-2">
<a href="{{ url_path('/projects/' ~ project.id ~ '/suggest') }}" class="btn btn-sm btn-outline-secondary">
🔍 Подобрать похожие на PropertyFinder
</a>
<span class="text-muted small ms-2">
— по зданию из вашего объявления{% if project.bedrooms is not none %}, {{ project.bedrooms }} BR{% endif %};
совпадения по DLD permit — первыми. Займёт ~1520 сек.
</span>
</div>
{% else %}
<div class="mt-2 text-muted small">
🔍 «Подобрать похожие» появится, когда у проекта заполнены <b>наше объявление (URL)</b> и <b>спальни</b>.
</div>
{% endif %}
</div>
<h5 class="mb-3">Отслеживаемые конкуренты ({{ project.listings|length }})</h5>
{% if not project.listings %}
<div class="alert alert-light border">
Пока ничего не отслеживается. Добавьте URL объявления конкурента выше.
</div>
{% else %}
{% for l in project.listings %}
<div class="card mb-3 listing-card {{ 'removed' if l.status.value == 'removed' else 'active' }}">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between gap-2">
<div>
<span class="src-{{ 'pf' if l.source.value == 'propertyfinder' else 'bayut' }}">
{{ 'PropertyFinder' if l.source.value == 'propertyfinder' else 'Bayut' }}
</span>
{% if l.status.value == 'removed' %}<span class="badge bg-danger ms-2">Удалено</span>{% endif %}
<h6 class="mt-1 mb-1">
<a href="{{ l.url }}" target="_blank" rel="noopener">{{ l.title or 'без названия' }}</a>
</h6>
<div class="text-muted small">
Брокер: {{ l.agent_name or '—' }} ({{ l.agency_name or '—' }})<br>
Добавлено: {{ l.first_seen_at | msk }} ·
Последний раз видели активным: {{ l.last_seen_at | msk }} (МСК)
</div>
</div>
<div class="text-sm-end flex-shrink-0">
<div class="fs-4 fw-bold">
{% if l.current_price %}
{{ "{:,.0f}".format(l.current_price).replace(",", " ") }} {{ l.currency or 'AED' }}
{% else %}—{% endif %}
</div>
{% if request.state.is_admin %}
<form method="post" action="{{ url_path('/listings/' ~ l.id ~ '/delete') }}"
onsubmit="return confirm('Перестать отслеживать?');" class="mt-2">
<button class="btn btn-sm btn-outline-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if l.price_history|length > 1 %}
<details class="mt-2">
<summary class="text-muted small">История цены ({{ l.price_history|length }} записей)</summary>
<ul class="small mt-2">
{% for h in l.price_history %}
<li>{{ h.recorded_at | msk }} —
{% if h.price %}{{ "{:,.0f}".format(h.price).replace(",", " ") }} AED{% else %}—{% endif %}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Новый проект — DLD Monitor{% endblock %}
{% block content %}
<h3>Новый проект</h3>
{% if no_employees %}
<div class="alert alert-warning">
Сначала добавьте хотя бы одного <a href="{{ url_path('/employees') }}">сотрудника</a> — он будет получать уведомления в Telegram.
</div>
{% endif %}
<form method="post" action="{{ url_path('/projects/new') }}" class="bg-white p-4 rounded shadow-sm">
<div class="mb-3">
<label class="form-label">Название квартиры / лота</label>
<input name="title" required class="form-control" placeholder="Например: Aykon City Tower B, 2BR, Apt 1502">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Тип сделки</label>
<select name="deal_type" class="form-select" required>
<option value="sale">Продажа</option>
<option value="rent">Аренда</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Наша цена (AED)</label>
<input name="our_price" type="number" step="1" class="form-control" placeholder="например 1 720 000">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Владелец (сотрудник)</label>
<select name="owner_id" class="form-select" required {% if no_employees %}disabled{% endif %}>
{% for e in employees %}
<option value="{{ e.id }}">{{ e.name }}{% if e.tg_chat_id %} ✓ TG{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<hr>
<p class="text-muted small mb-3">
Поля ниже опциональны, но позволят системе предлагать «похожие» объявления конкурентов с PF и Bayut.
</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Здание / проект</label>
<input name="building" class="form-control" placeholder="Aykon City Tower B">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Спальни</label>
<input name="bedrooms" type="number" min="0" max="20" class="form-control" placeholder="2">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Площадь (sqft)</label>
<input name="size_sqft" type="number" step="1" class="form-control" placeholder="1058">
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">Ссылка на наше объявление (опционально)</label>
<input name="our_url" type="url" class="form-control" placeholder="https://www.propertyfinder.ae/...">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">DLD permit (опционально)</label>
<input name="dld_permit" class="form-control" placeholder="71-1-1-...">
</div>
</div>
<div class="mb-3">
<label class="form-label">Заметка</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<button class="btn btn-primary" {% if no_employees %}disabled{% endif %}>Создать</button>
<a href="{{ url_path('/') }}" class="btn btn-link">Отмена</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Проекты — DLD Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Наши проекты</h3>
<a href="{{ url_path('/projects/new') }}" class="btn btn-primary">+ Новый проект</a>
</div>
{% if projects %}
<div class="d-flex gap-2 mb-3">
<div class="stat-chip"><div class="n">{{ projects|length }}</div><div class="small text-muted">проектов</div></div>
<div class="stat-chip"><div class="n">{{ projects|map(attribute='listings')|map('length')|sum }}</div><div class="small text-muted">конкурентов</div></div>
</div>
{% endif %}
{% if not projects %}
<div class="alert alert-light border text-center py-5">
Пока ни одного проекта. <a href="{{ url_path('/projects/new') }}">Добавьте первый</a>.
</div>
{% else %}
<div class="table-responsive bg-white rounded shadow-sm">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Здание</th>
<th>Тип</th>
<th>Наша цена</th>
<th>Конкуренты</th>
<th>Владелец</th>
<th>Последняя проверка (МСК)</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr>
<td><a href="{{ url_path('/projects/' ~ p.id) }}">{{ p.title }}</a></td>
<td class="small">
{% if p.building %}{{ p.building }}{% else %}—{% endif %}
{% if p.bedrooms is not none %} · {{ p.bedrooms }}BR{% endif %}
</td>
<td>
{% if p.deal_type.value == 'sale' %}
<span class="badge badge-sale">Продажа</span>
{% else %}
<span class="badge badge-rent">Аренда</span>
{% endif %}
</td>
<td>{% if p.our_price %}{{ "{:,.0f}".format(p.our_price).replace(",", " ") }} AED{% else %}—{% endif %}</td>
<td>{{ p.listings|length }}</td>
<td>{{ p.owner.name if p.owner else '—' }}</td>
<td>{{ p.last_checked_at | msk }}</td>
<td>
<form action="{{ url_path('/projects/' ~ p.id ~ '/check') }}" method="post" class="d-inline">
<button class="btn btn-sm btn-outline-primary">Проверить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

138
app/templates/suggest.html Normal file
View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}Похожие — {{ project.title }}{% endblock %}
{% block head %}
<style>
.suggest-card { cursor: pointer; transition: border-color .12s, box-shadow .12s; }
.suggest-card:hover { border-color: #adb5bd; }
.suggest-card.selected { border-color: #0d6efd; box-shadow: 0 0 0 1px #0d6efd inset; }
.suggest-card a { cursor: pointer; }
/* Sticky action bar so the submit button is always reachable, esp. on mobile */
.bulk-bar {
position: sticky; bottom: 0; z-index: 1020;
background: #fff; border-top: 1px solid #e9ecef;
padding: .6rem .25rem; margin-top: 1rem;
box-shadow: 0 -4px 12px rgba(0,0,0,.05);
}
@media (max-width: 575.98px) {
.bulk-bar #bulk-submit { flex: 1; }
}
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_path('/projects/' ~ project.id) }}" class="btn btn-sm btn-link px-0">← к проекту</a>
</div>
<h3 class="mb-1">Подсказки похожих объявлений</h3>
<p class="text-muted">
Ищем объявления в том же здании{% if project.bedrooms is not none %}, {{ project.bedrooms }} спален{% endif %},
тип сделки: {{ project.deal_type.value }}.
{% if our_permit %}Совпадение по DLD permit (<code>{{ our_permit }}</code>) — это тот же объект, такие показаны первыми. {% endif %}
Отметьте подходящие объявления галочкой (можно несколько) и нажмите «Отслеживать выбранные» — они попадут в трекинг.
</p>
<form id="bulk-form" method="post" action="{{ url_path('/projects/' ~ project.id ~ '/listings/bulk') }}">
{% for src_key, src_label in [('propertyfinder', 'PropertyFinder'), ('bayut', 'Bayut')] %}
<h5 class="mt-4">
<span class="src-{{ 'pf' if src_key == 'propertyfinder' else 'bayut' }}">{{ src_label }}</span>
{% if src_key == 'bayut' and not bayut_enabled %}
<small class="text-muted">— ⏸ временно отключён</small>
{% else %}
<small class="text-muted">— найдено {{ suggestions[src_key]|length }}</small>
{% endif %}
</h5>
{% if src_key == 'bayut' and not bayut_enabled %}
<div class="text-muted small">Bayut перешёл на защищённый рендеринг — поиск и трекинг временно недоступны.</div>
{% elif not suggestions[src_key] %}
<div class="text-muted small">Ничего не нашли (или площадка заблокировала запрос).</div>
{% else %}
{% for s in suggestions[src_key] %}
{% set same_permit = our_permit and s.permit_number and s.permit_number == our_permit %}
<label class="card mb-2 suggest-card{% if same_permit %} border-success{% endif %}">
<div class="card-body py-2">
<div class="d-flex gap-2 align-items-start">
<input class="form-check-input suggest-check flex-shrink-0 mt-1" type="checkbox"
name="urls" value="{{ s.url }}">
<div class="flex-grow-1">
<div class="d-flex flex-column flex-sm-row justify-content-between gap-1">
<div>
{% if same_permit %}<span class="badge bg-success">🎯 тот же permit</span> {% endif %}
<a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title or 'без названия' }}</a>
<div class="small text-muted">
{{ s.agent_name or '—' }} ({{ s.agency_name or '—' }})
{% if s.permit_number %} · permit <code>{{ s.permit_number }}</code>{% endif %}
</div>
</div>
<div class="fw-bold text-sm-end text-nowrap">
{% if s.price %}{{ "{:,.0f}".format(s.price).replace(",", " ") }} {{ s.currency or 'AED' }}{% else %}—{% endif %}
</div>
</div>
</div>
</div>
</div>
</label>
{% endfor %}
{% endif %}
{% endfor %}
<div class="bulk-bar">
<div class="d-flex align-items-center gap-3">
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" id="select-all">
<label class="form-check-label small" for="select-all">Выбрать все</label>
</div>
<button type="submit" class="btn btn-primary ms-auto" id="bulk-submit" disabled>
+ Отслеживать выбранные (<span id="sel-count">0</span>)
</button>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById('bulk-form');
if (!form) return;
const checks = Array.from(form.querySelectorAll('.suggest-check'));
const selectAll = document.getElementById('select-all');
const countEl = document.getElementById('sel-count');
const submitBtn = document.getElementById('bulk-submit');
function refresh() {
let n = 0;
checks.forEach(c => {
const card = c.closest('.suggest-card');
if (card) card.classList.toggle('selected', c.checked);
if (c.checked) n++;
});
countEl.textContent = n;
submitBtn.disabled = n === 0;
if (selectAll) selectAll.checked = n > 0 && n === checks.length;
}
checks.forEach(c => c.addEventListener('change', refresh));
if (selectAll) {
selectAll.addEventListener('change', () => {
checks.forEach(c => { c.checked = selectAll.checked; });
refresh();
});
}
// Confirm before submitting a large batch — each add re-fetches the page.
form.addEventListener('submit', e => {
const n = checks.filter(c => c.checked).length;
if (n === 0) { e.preventDefault(); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Добавляем…';
});
refresh();
})();
</script>
{% endblock %}