merge: bulk actions for articles and leads
This commit is contained in:
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action.
|
||||||
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
|
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
|
||||||
- `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
|
- `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
|
||||||
- `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
|
- `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
|
||||||
|
|||||||
@@ -532,6 +532,71 @@ async def lead_results():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/bulk", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def leads_bulk():
|
||||||
|
"""Bulk actions on leads: set_status, set_heat."""
|
||||||
|
form = await request.form
|
||||||
|
ids_raw = form.get("lead_ids", "").strip()
|
||||||
|
action = form.get("action", "").strip()
|
||||||
|
|
||||||
|
if action not in ("set_status", "set_heat") or not ids_raw:
|
||||||
|
return "", 400
|
||||||
|
|
||||||
|
lead_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
|
||||||
|
assert len(lead_ids) <= 500, "too many lead IDs in bulk action"
|
||||||
|
if not lead_ids:
|
||||||
|
return "", 400
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in lead_ids)
|
||||||
|
|
||||||
|
if action == "set_status":
|
||||||
|
target = form.get("target_status", "").strip()
|
||||||
|
if target not in LEAD_STATUSES:
|
||||||
|
return "", 400
|
||||||
|
await execute(
|
||||||
|
f"UPDATE lead_requests SET status = ? WHERE id IN ({placeholders})",
|
||||||
|
(target, *lead_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "set_heat":
|
||||||
|
target = form.get("target_heat", "").strip()
|
||||||
|
if target not in HEAT_OPTIONS:
|
||||||
|
return "", 400
|
||||||
|
await execute(
|
||||||
|
f"UPDATE lead_requests SET heat_score = ? WHERE id IN ({placeholders})",
|
||||||
|
(target, *lead_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-render results partial with current filters
|
||||||
|
search = form.get("search", "").strip()
|
||||||
|
status_filter = form.get("status", "")
|
||||||
|
heat_filter = form.get("heat", "")
|
||||||
|
country_filter = form.get("country", "")
|
||||||
|
days_str = form.get("days", "")
|
||||||
|
days = int(days_str) if days_str.isdigit() else None
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
|
lead_list, total = await get_leads(
|
||||||
|
status=status_filter or None, heat=heat_filter or None,
|
||||||
|
country=country_filter or None, search=search or None,
|
||||||
|
days=days, page=1, per_page=per_page,
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/lead_results.html",
|
||||||
|
leads=lead_list,
|
||||||
|
page=1,
|
||||||
|
per_page=per_page,
|
||||||
|
total=total,
|
||||||
|
current_status=status_filter,
|
||||||
|
current_heat=heat_filter,
|
||||||
|
current_country=country_filter,
|
||||||
|
current_search=search,
|
||||||
|
current_days=days_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/leads/<int:lead_id>")
|
@bp.route("/leads/<int:lead_id>")
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
async def lead_detail(lead_id: int):
|
async def lead_detail(lead_id: int):
|
||||||
@@ -2430,6 +2495,101 @@ async def article_results():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/articles/bulk", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def articles_bulk():
|
||||||
|
"""Bulk actions on articles: publish, unpublish, toggle_noindex, rebuild, delete."""
|
||||||
|
form = await request.form
|
||||||
|
ids_raw = form.get("article_ids", "").strip()
|
||||||
|
action = form.get("action", "").strip()
|
||||||
|
|
||||||
|
valid_actions = ("publish", "unpublish", "toggle_noindex", "rebuild", "delete")
|
||||||
|
if action not in valid_actions or not ids_raw:
|
||||||
|
return "", 400
|
||||||
|
|
||||||
|
article_ids = [int(i) for i in ids_raw.split(",") if i.strip().isdigit()]
|
||||||
|
assert len(article_ids) <= 500, "too many article IDs in bulk action"
|
||||||
|
if not article_ids:
|
||||||
|
return "", 400
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in article_ids)
|
||||||
|
now = utcnow_iso()
|
||||||
|
|
||||||
|
if action == "publish":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET status = 'published', updated_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(now, *article_ids),
|
||||||
|
)
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
elif action == "unpublish":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET status = 'draft', updated_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(now, *article_ids),
|
||||||
|
)
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
elif action == "toggle_noindex":
|
||||||
|
await execute(
|
||||||
|
f"UPDATE articles SET noindex = CASE WHEN noindex = 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(now, *article_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "rebuild":
|
||||||
|
for aid in article_ids:
|
||||||
|
await _rebuild_article(aid)
|
||||||
|
|
||||||
|
elif action == "delete":
|
||||||
|
from ..content.routes import BUILD_DIR
|
||||||
|
|
||||||
|
articles = await fetch_all(
|
||||||
|
f"SELECT id, slug FROM articles WHERE id IN ({placeholders})",
|
||||||
|
tuple(article_ids),
|
||||||
|
)
|
||||||
|
for a in articles:
|
||||||
|
build_path = BUILD_DIR / f"{a['slug']}.html"
|
||||||
|
if build_path.exists():
|
||||||
|
build_path.unlink()
|
||||||
|
md_path = Path("data/content/articles") / f"{a['slug']}.md"
|
||||||
|
if md_path.exists():
|
||||||
|
md_path.unlink()
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
f"DELETE FROM articles WHERE id IN ({placeholders})",
|
||||||
|
tuple(article_ids),
|
||||||
|
)
|
||||||
|
from ..sitemap import invalidate_sitemap_cache
|
||||||
|
invalidate_sitemap_cache()
|
||||||
|
|
||||||
|
# Re-render results partial with current filters
|
||||||
|
search = form.get("search", "").strip()
|
||||||
|
status_filter = form.get("status", "")
|
||||||
|
template_filter = form.get("template", "")
|
||||||
|
language_filter = form.get("language", "")
|
||||||
|
|
||||||
|
grouped = not language_filter
|
||||||
|
if grouped:
|
||||||
|
article_list = await _get_article_list_grouped(
|
||||||
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
search=search or None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
article_list = await _get_article_list(
|
||||||
|
status=status_filter or None, template_slug=template_filter or None,
|
||||||
|
language=language_filter or None, search=search or None,
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/article_results.html",
|
||||||
|
articles=article_list,
|
||||||
|
grouped=grouped,
|
||||||
|
page=1,
|
||||||
|
is_generating=await _is_generating(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/articles/new", methods=["GET", "POST"])
|
@bp.route("/articles/new", methods=["GET", "POST"])
|
||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
|
|||||||
@@ -70,8 +70,91 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Bulk action bar #}
|
||||||
|
<form id="article-bulk-form" style="display:none">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
|
||||||
|
<input type="hidden" name="action" id="article-bulk-action" value="">
|
||||||
|
<input type="hidden" name="search" value="{{ current_search }}">
|
||||||
|
<input type="hidden" name="status" value="{{ current_status }}">
|
||||||
|
<input type="hidden" name="template" value="{{ current_template }}">
|
||||||
|
<input type="hidden" name="language" value="{{ current_language }}">
|
||||||
|
</form>
|
||||||
|
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||||
|
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||||
|
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
|
||||||
|
<option value="">Action…</option>
|
||||||
|
<option value="publish">Publish</option>
|
||||||
|
<option value="unpublish">Unpublish</option>
|
||||||
|
<option value="toggle_noindex">Toggle noindex</option>
|
||||||
|
<option value="rebuild">Rebuild</option>
|
||||||
|
<option value="delete">Delete</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
|
||||||
|
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Results #}
|
{# Results #}
|
||||||
<div id="article-results">
|
<div id="article-results">
|
||||||
{% include "admin/partials/article_results.html" %}
|
{% include "admin/partials/article_results.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const articleSelectedIds = new Set();
|
||||||
|
|
||||||
|
function toggleArticleSelect(id, checked) {
|
||||||
|
if (checked) articleSelectedIds.add(id);
|
||||||
|
else articleSelectedIds.delete(id);
|
||||||
|
updateArticleBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearArticleSelection() {
|
||||||
|
articleSelectedIds.clear();
|
||||||
|
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
|
||||||
|
var selectAll = document.getElementById('article-select-all');
|
||||||
|
if (selectAll) selectAll.checked = false;
|
||||||
|
updateArticleBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateArticleBulkBar() {
|
||||||
|
var bar = document.getElementById('article-bulk-bar');
|
||||||
|
var count = document.getElementById('article-bulk-count');
|
||||||
|
var ids = document.getElementById('article-bulk-ids');
|
||||||
|
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
|
||||||
|
count.textContent = articleSelectedIds.size + ' selected';
|
||||||
|
ids.value = Array.from(articleSelectedIds).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitArticleBulk() {
|
||||||
|
var action = document.getElementById('article-bulk-action-select').value;
|
||||||
|
if (!action) return;
|
||||||
|
if (articleSelectedIds.size === 0) return;
|
||||||
|
|
||||||
|
function doSubmit() {
|
||||||
|
document.getElementById('article-bulk-action').value = action;
|
||||||
|
htmx.ajax('POST', '{{ url_for("admin.articles_bulk") }}', {
|
||||||
|
source: document.getElementById('article-bulk-form'),
|
||||||
|
target: '#article-results',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
clearArticleSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
|
||||||
|
if (ok) doSubmit();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
doSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target.id === 'article-results') {
|
||||||
|
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
|
||||||
|
if (articleSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -126,8 +126,103 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk action bar -->
|
||||||
|
<form id="lead-bulk-form" style="display:none">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="lead_ids" id="lead-bulk-ids" value="">
|
||||||
|
<input type="hidden" name="action" id="lead-bulk-action" value="">
|
||||||
|
<input type="hidden" name="target_status" id="lead-bulk-target-status" value="">
|
||||||
|
<input type="hidden" name="target_heat" id="lead-bulk-target-heat" value="">
|
||||||
|
<input type="hidden" name="search" value="{{ current_search }}">
|
||||||
|
<input type="hidden" name="status" value="{{ current_status }}">
|
||||||
|
<input type="hidden" name="heat" value="{{ current_heat }}">
|
||||||
|
<input type="hidden" name="country" value="{{ current_country }}">
|
||||||
|
<input type="hidden" name="days" value="{{ current_days }}">
|
||||||
|
</form>
|
||||||
|
<div id="lead-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||||
|
<span id="lead-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||||
|
<select id="lead-bulk-action-select" class="form-input" style="min-width:120px;padding:0.25rem 0.5rem;font-size:0.8125rem" onchange="onLeadActionChange()">
|
||||||
|
<option value="">Action…</option>
|
||||||
|
<option value="set_status">Set Status</option>
|
||||||
|
<option value="set_heat">Set Heat</option>
|
||||||
|
</select>
|
||||||
|
<select id="lead-status-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}">{{ s | replace('_', ' ') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="lead-heat-select" class="form-input" style="min-width:100px;padding:0.25rem 0.5rem;font-size:0.8125rem;display:none">
|
||||||
|
{% for h in heat_options %}
|
||||||
|
<option value="{{ h }}">{{ h | upper }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm" onclick="submitLeadBulk()">Apply</button>
|
||||||
|
<button type="button" class="btn-outline btn-sm" onclick="clearLeadSelection()">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div id="lead-results">
|
<div id="lead-results">
|
||||||
{% include "admin/partials/lead_results.html" %}
|
{% include "admin/partials/lead_results.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const leadSelectedIds = new Set();
|
||||||
|
|
||||||
|
function toggleLeadSelect(id, checked) {
|
||||||
|
if (checked) leadSelectedIds.add(id);
|
||||||
|
else leadSelectedIds.delete(id);
|
||||||
|
updateLeadBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLeadSelection() {
|
||||||
|
leadSelectedIds.clear();
|
||||||
|
document.querySelectorAll('.lead-checkbox').forEach(function(cb) { cb.checked = false; });
|
||||||
|
var selectAll = document.getElementById('lead-select-all');
|
||||||
|
if (selectAll) selectAll.checked = false;
|
||||||
|
updateLeadBulkBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLeadBulkBar() {
|
||||||
|
var bar = document.getElementById('lead-bulk-bar');
|
||||||
|
var count = document.getElementById('lead-bulk-count');
|
||||||
|
var ids = document.getElementById('lead-bulk-ids');
|
||||||
|
bar.style.display = leadSelectedIds.size > 0 ? 'flex' : 'none';
|
||||||
|
count.textContent = leadSelectedIds.size + ' selected';
|
||||||
|
ids.value = Array.from(leadSelectedIds).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeadActionChange() {
|
||||||
|
var action = document.getElementById('lead-bulk-action-select').value;
|
||||||
|
document.getElementById('lead-status-select').style.display = action === 'set_status' ? '' : 'none';
|
||||||
|
document.getElementById('lead-heat-select').style.display = action === 'set_heat' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitLeadBulk() {
|
||||||
|
var action = document.getElementById('lead-bulk-action-select').value;
|
||||||
|
if (!action) return;
|
||||||
|
if (leadSelectedIds.size === 0) return;
|
||||||
|
|
||||||
|
document.getElementById('lead-bulk-action').value = action;
|
||||||
|
if (action === 'set_status') {
|
||||||
|
document.getElementById('lead-bulk-target-status').value = document.getElementById('lead-status-select').value;
|
||||||
|
} else if (action === 'set_heat') {
|
||||||
|
document.getElementById('lead-bulk-target-heat').value = document.getElementById('lead-heat-select').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.ajax('POST', '{{ url_for("admin.leads_bulk") }}', {
|
||||||
|
source: document.getElementById('lead-bulk-form'),
|
||||||
|
target: '#lead-results',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
clearLeadSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target.id === 'lead-results') {
|
||||||
|
document.querySelectorAll('.lead-checkbox').forEach(function(cb) {
|
||||||
|
if (leadSelectedIds.has(Number(cb.dataset.id))) cb.checked = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
|
<tr id="article-group-{{ g.url_path | replace('/', '-') | trim('-') }}">
|
||||||
|
<td></td>
|
||||||
<td style="max-width:260px">
|
<td style="max-width:260px">
|
||||||
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
|
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500" title="{{ g.url_path }}">{{ g.title }}</div>
|
||||||
<div class="article-subtitle">{{ g.url_path }}</div>
|
<div class="article-subtitle">{{ g.url_path }}</div>
|
||||||
|
|||||||
@@ -54,6 +54,11 @@
|
|||||||
<table class="table text-sm">
|
<table class="table text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{% if not grouped %}
|
||||||
|
<th style="width:32px"><input type="checkbox" id="article-select-all" onchange="document.querySelectorAll('.article-checkbox').forEach(cb => { cb.checked = this.checked; toggleArticleSelect(Number(cb.dataset.id), this.checked); })"></th>
|
||||||
|
{% else %}
|
||||||
|
<th style="width:32px"></th>
|
||||||
|
{% endif %}
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
|
<th>{% if grouped %}Variants{% else %}Status{% endif %}</th>
|
||||||
<th>Published</th>
|
<th>Published</th>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<tr id="article-{{ a.id }}">
|
<tr id="article-{{ a.id }}">
|
||||||
|
<td onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="article-checkbox" data-id="{{ a.id }}"
|
||||||
|
onchange="toggleArticleSelect({{ a.id }}, this.checked)">
|
||||||
|
</td>
|
||||||
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
|
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"
|
||||||
title="{{ a.url_path }}">{{ a.title }}</td>
|
title="{{ a.url_path }}">{{ a.title }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:32px"><input type="checkbox" id="lead-select-all" onchange="document.querySelectorAll('.lead-checkbox').forEach(cb => { cb.checked = this.checked; toggleLeadSelect(Number(cb.dataset.id), this.checked); })"></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Heat</th>
|
<th>Heat</th>
|
||||||
<th>Contact</th>
|
<th>Contact</th>
|
||||||
@@ -43,6 +44,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for lead in leads %}
|
{% for lead in leads %}
|
||||||
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
||||||
|
<td onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="lead-checkbox" data-id="{{ lead.id }}"
|
||||||
|
onchange="toggleLeadSelect({{ lead.id }}, this.checked)">
|
||||||
|
</td>
|
||||||
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
||||||
<td>{{ heat_badge(lead.heat_score) }}</td>
|
<td>{{ heat_badge(lead.heat_score) }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in New Issue
Block a user