feat(admin): cross-section links across leads, suppliers, marketplace, emails
Lead detail: - contact_email → 📧 email log (pre-filtered), mailto, Send Email compose - country → leads list filtered by that country Supplier detail: - contact_email → 📧 email log (pre-filtered), mailto, Send Email compose - claimed_by → user detail page (was plain "User #N") Marketplace dashboard: - Funnel card numbers are now links: Total → /leads, Verified New → /leads?status=new, Unlocked → /leads?status=forwarded, Won → /leads?status=closed_won - Active suppliers number links to /suppliers Marketplace activity stream: - lead events → link to lead_detail - unlock events → supplier name links to supplier_detail, "lead #N" links to lead_detail - credit events → supplier name links to supplier_detail (query now joins suppliers table for name; ref2_id exposes supplier_id and lead_id per event) Email detail: - Reverse-lookup to_addr against lead_requests + suppliers; renders linked "Lead #N" / "Supplier Name" chips next to the To field Email compose: - Accepts ?to= query param to pre-fill recipient (enables Send Email links) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -829,21 +829,22 @@ async def marketplace_dashboard():
|
|||||||
async def marketplace_activity():
|
async def marketplace_activity():
|
||||||
"""HTMX: Recent marketplace activity stream."""
|
"""HTMX: Recent marketplace activity stream."""
|
||||||
rows = await fetch_all(
|
rows = await fetch_all(
|
||||||
"""SELECT 'lead' as event_type, id as ref_id,
|
"""SELECT 'lead' as event_type, id as ref_id, NULL as ref2_id,
|
||||||
contact_name as actor, status as detail,
|
contact_name as actor, status as detail,
|
||||||
country as extra, created_at
|
country as extra, created_at
|
||||||
FROM lead_requests WHERE lead_type = 'quote'
|
FROM lead_requests WHERE lead_type = 'quote'
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'unlock' as event_type, lf.id as ref_id,
|
SELECT 'unlock' as event_type, lf.lead_id as ref_id, lf.supplier_id as ref2_id,
|
||||||
s.name as actor, lf.status as detail,
|
s.name as actor, lf.status as detail,
|
||||||
CAST(lf.credit_cost AS TEXT) as extra, lf.created_at
|
CAST(lf.credit_cost AS TEXT) as extra, lf.created_at
|
||||||
FROM lead_forwards lf
|
FROM lead_forwards lf
|
||||||
JOIN suppliers s ON s.id = lf.supplier_id
|
JOIN suppliers s ON s.id = lf.supplier_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'credit' as event_type, id as ref_id,
|
SELECT 'credit' as event_type, id as ref_id, supplier_id as ref2_id,
|
||||||
CAST(supplier_id AS TEXT) as actor, event_type as detail,
|
s.name as actor, cl.event_type as detail,
|
||||||
CAST(delta AS TEXT) as extra, created_at
|
CAST(cl.delta AS TEXT) as extra, cl.created_at
|
||||||
FROM credit_ledger
|
FROM credit_ledger cl
|
||||||
|
JOIN suppliers s ON s.id = cl.supplier_id
|
||||||
ORDER BY created_at DESC LIMIT 50"""
|
ORDER BY created_at DESC LIMIT 50"""
|
||||||
)
|
)
|
||||||
return await render_template("admin/partials/marketplace_activity.html", events=rows)
|
return await render_template("admin/partials/marketplace_activity.html", events=rows)
|
||||||
@@ -1285,10 +1286,19 @@ async def email_detail(email_id: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to fetch email body from Resend for %s", email["resend_id"], exc_info=True)
|
logger.warning("Failed to fetch email body from Resend for %s", email["resend_id"], exc_info=True)
|
||||||
|
|
||||||
|
related_lead = await fetch_one(
|
||||||
|
"SELECT id FROM lead_requests WHERE contact_email = ? LIMIT 1", (email["to_addr"],)
|
||||||
|
)
|
||||||
|
related_supplier = await fetch_one(
|
||||||
|
"SELECT id, name FROM suppliers WHERE contact_email = ? LIMIT 1", (email["to_addr"],)
|
||||||
|
)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/email_detail.html",
|
"admin/email_detail.html",
|
||||||
email=email,
|
email=email,
|
||||||
enriched_html=enriched_html,
|
enriched_html=enriched_html,
|
||||||
|
related_lead=related_lead,
|
||||||
|
related_supplier=related_supplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1408,8 +1418,9 @@ async def email_compose():
|
|||||||
email_addresses=EMAIL_ADDRESSES,
|
email_addresses=EMAIL_ADDRESSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prefill_to = request.args.get("to", "")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/email_compose.html", data={}, email_addresses=EMAIL_ADDRESSES,
|
"admin/email_compose.html", data={"to": prefill_to}, email_addresses=EMAIL_ADDRESSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,15 @@
|
|||||||
<h2 class="text-lg mb-4">Details</h2>
|
<h2 class="text-lg mb-4">Details</h2>
|
||||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">To</dt>
|
<dt class="text-slate">To</dt>
|
||||||
<dd>{{ email.to_addr }}</dd>
|
<dd class="flex items-center gap-2 flex-wrap">
|
||||||
|
{{ email.to_addr }}
|
||||||
|
{% if related_lead %}
|
||||||
|
<a href="{{ url_for('admin.lead_detail', lead_id=related_lead.id) }}" class="badge" style="background:#DBEAFE;color:#1E40AF">Lead #{{ related_lead.id }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if related_supplier %}
|
||||||
|
<a href="{{ url_for('admin.supplier_detail', supplier_id=related_supplier.id) }}" class="badge" style="background:#D1FAE5;color:#065F46">{{ related_supplier.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
<dt class="text-slate">From</dt>
|
<dt class="text-slate">From</dt>
|
||||||
<dd>{{ email.from_addr }}</dd>
|
<dd>{{ email.from_addr }}</dd>
|
||||||
<dt class="text-slate">Subject</dt>
|
<dt class="text-slate">Subject</dt>
|
||||||
|
|||||||
@@ -62,7 +62,10 @@
|
|||||||
<dt class="text-slate">Courts</dt> <dd>{{ lead.court_count or '-' }}</dd>
|
<dt class="text-slate">Courts</dt> <dd>{{ lead.court_count or '-' }}</dd>
|
||||||
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
|
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
|
||||||
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
|
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
|
||||||
<dt class="text-slate">Location</dt> <dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
<dt class="text-slate">Location</dt>
|
||||||
|
<dd>{{ lead.location or '-' }}{% if lead.country %},
|
||||||
|
<a href="{{ url_for('admin.leads', country=lead.country) }}" class="text-sm">{{ lead.country }}</a>
|
||||||
|
{% else %}-{% endif %}</dd>
|
||||||
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
|
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
|
||||||
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
|
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
|
||||||
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</dd>
|
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</dd>
|
||||||
@@ -79,7 +82,15 @@
|
|||||||
<h2 class="text-lg mb-4">Contact</h2>
|
<h2 class="text-lg mb-4">Contact</h2>
|
||||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
|
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
|
||||||
<dt class="text-slate">Email</dt> <dd>{{ lead.contact_email or '-' }}</dd>
|
<dt class="text-slate">Email</dt>
|
||||||
|
<dd class="flex items-center gap-2 flex-wrap">
|
||||||
|
{{ lead.contact_email or '-' }}
|
||||||
|
{% if lead.contact_email %}
|
||||||
|
<a href="{{ url_for('admin.emails', search=lead.contact_email) }}" class="text-xs text-slate" title="Email log">📧</a>
|
||||||
|
<a href="mailto:{{ lead.contact_email }}" class="text-xs text-slate" title="mailto">✉</a>
|
||||||
|
<a href="{{ url_for('admin.email_compose') }}?to={{ lead.contact_email }}" class="btn-outline btn-sm" style="padding:1px 8px;font-size:0.7rem">Send email</a>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
|
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
|
||||||
<dt class="text-slate">Company</dt> <dd>{{ lead.contact_company or '-' }}</dd>
|
<dt class="text-slate">Company</dt> <dd>{{ lead.contact_company or '-' }}</dd>
|
||||||
<dt class="text-slate">Role</dt> <dd>{{ lead.stakeholder_type or '-' }}</dd>
|
<dt class="text-slate">Role</dt> <dd>{{ lead.stakeholder_type or '-' }}</dd>
|
||||||
|
|||||||
@@ -38,22 +38,22 @@
|
|||||||
<div class="grid-4 mb-8">
|
<div class="grid-4 mb-8">
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<p class="card-header">Total Leads</p>
|
<p class="card-header">Total Leads</p>
|
||||||
<p class="text-3xl font-bold text-navy">{{ funnel.total }}</p>
|
<a href="{{ url_for('admin.leads') }}" class="text-3xl font-bold text-navy">{{ funnel.total }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<p class="card-header">Verified New</p>
|
<p class="card-header">Verified New</p>
|
||||||
<p class="text-3xl font-bold text-navy">{{ funnel.verified_new }}</p>
|
<a href="{{ url_for('admin.leads', status='new') }}" class="text-3xl font-bold text-navy">{{ funnel.verified_new }}</a>
|
||||||
<p class="text-xs text-slate mt-1">ready to unlock</p>
|
<p class="text-xs text-slate mt-1">ready to unlock</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<p class="card-header">Unlocked</p>
|
<p class="card-header">Unlocked</p>
|
||||||
<p class="text-3xl font-bold text-navy">{{ funnel.unlocked }}</p>
|
<a href="{{ url_for('admin.leads', status='forwarded') }}" class="text-3xl font-bold text-navy">{{ funnel.unlocked }}</a>
|
||||||
<p class="text-xs text-slate mt-1">by suppliers</p>
|
<p class="text-xs text-slate mt-1">by suppliers</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<p class="card-header">Conversion</p>
|
<p class="card-header">Conversion</p>
|
||||||
<p class="text-3xl font-bold {% if funnel.conversion_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ funnel.conversion_rate }}%</p>
|
<p class="text-3xl font-bold {% if funnel.conversion_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ funnel.conversion_rate }}%</p>
|
||||||
<p class="text-xs text-slate mt-1">{{ funnel.won }} won</p>
|
<p class="text-xs text-slate mt-1"><a href="{{ url_for('admin.leads', status='closed_won') }}">{{ funnel.won }} won</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-slate">Active suppliers</p>
|
<p class="text-xs text-slate">Active suppliers</p>
|
||||||
<p class="text-2xl font-bold text-navy">{{ suppliers.active }}</p>
|
<a href="{{ url_for('admin.suppliers') }}" class="text-2xl font-bold text-navy">{{ suppliers.active }}</a>
|
||||||
<p class="text-xs text-slate mt-1">growth/pro w/ credits</p>
|
<p class="text-xs text-slate mt-1">growth/pro w/ credits</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,16 +7,17 @@
|
|||||||
<span class="activity-dot activity-dot-{{ ev.event_type }}"></span>
|
<span class="activity-dot activity-dot-{{ ev.event_type }}"></span>
|
||||||
<div style="flex:1;font-size:0.8125rem">
|
<div style="flex:1;font-size:0.8125rem">
|
||||||
{% if ev.event_type == 'lead' %}
|
{% if ev.event_type == 'lead' %}
|
||||||
<span class="font-semibold">New lead</span>
|
<span class="font-semibold"><a href="{{ url_for('admin.lead_detail', lead_id=ev.ref_id) }}">New lead</a></span>
|
||||||
{% if ev.actor %} from {{ ev.actor }}{% endif %}
|
{% if ev.actor %} from {{ ev.actor }}{% endif %}
|
||||||
{% if ev.extra %} — {{ ev.extra }}{% endif %}
|
{% if ev.extra %} — {{ ev.extra }}{% endif %}
|
||||||
{% if ev.detail %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
{% if ev.detail %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
{% elif ev.event_type == 'unlock' %}
|
{% elif ev.event_type == 'unlock' %}
|
||||||
<span class="font-semibold">{{ ev.actor }}</span> unlocked a lead
|
<a href="{{ url_for('admin.supplier_detail', supplier_id=ev.ref2_id) }}" class="font-semibold">{{ ev.actor }}</a>
|
||||||
|
unlocked <a href="{{ url_for('admin.lead_detail', lead_id=ev.ref_id) }}">lead #{{ ev.ref_id }}</a>
|
||||||
{% if ev.extra %} — {{ ev.extra }} credits{% endif %}
|
{% if ev.extra %} — {{ ev.extra }} credits{% endif %}
|
||||||
{% if ev.detail and ev.detail != 'sent' %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
{% if ev.detail and ev.detail != 'sent' %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
{% elif ev.event_type == 'credit' %}
|
{% elif ev.event_type == 'credit' %}
|
||||||
Credit event
|
<a href="{{ url_for('admin.supplier_detail', supplier_id=ev.ref2_id) }}">{{ ev.actor }}</a>
|
||||||
{% if ev.extra and ev.extra | int > 0 %}<span class="text-slate">+{{ ev.extra }}</span>
|
{% if ev.extra and ev.extra | int > 0 %}<span class="text-slate">+{{ ev.extra }}</span>
|
||||||
{% elif ev.extra %}<span class="text-slate">{{ ev.extra }}</span>{% endif %}
|
{% elif ev.extra %}<span class="text-slate">{{ ev.extra }}</span>{% endif %}
|
||||||
{% if ev.detail %} <span class="text-xs text-slate">({{ ev.detail }})</span>{% endif %}
|
{% if ev.detail %} <span class="text-xs text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
|
|||||||
@@ -45,7 +45,14 @@
|
|||||||
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
|
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
|
||||||
<dt class="text-slate">Contact</dt>
|
<dt class="text-slate">Contact</dt>
|
||||||
<dd>{{ supplier.contact_name or '-' }}<br>
|
<dd>{{ supplier.contact_name or '-' }}<br>
|
||||||
<span class="text-xs text-slate">{{ supplier.contact_email or '-' }}</span></dd>
|
<span class="text-xs text-slate flex items-center gap-2 flex-wrap mt-1">
|
||||||
|
{{ supplier.contact_email or '-' }}
|
||||||
|
{% if supplier.contact_email %}
|
||||||
|
<a href="{{ url_for('admin.emails', search=supplier.contact_email) }}" title="Email log">📧</a>
|
||||||
|
<a href="mailto:{{ supplier.contact_email }}" title="mailto">✉</a>
|
||||||
|
<a href="{{ url_for('admin.email_compose') }}?to={{ supplier.contact_email }}" class="btn-outline btn-sm" style="padding:1px 8px;font-size:0.7rem">Send email</a>
|
||||||
|
{% endif %}
|
||||||
|
</span></dd>
|
||||||
<dt class="text-slate">Tagline</dt>
|
<dt class="text-slate">Tagline</dt>
|
||||||
<dd>{{ supplier.tagline or '-' }}</dd>
|
<dd>{{ supplier.tagline or '-' }}</dd>
|
||||||
<dt class="text-slate">Description</dt>
|
<dt class="text-slate">Description</dt>
|
||||||
@@ -73,7 +80,7 @@
|
|||||||
<dt class="text-slate">Enquiries</dt>
|
<dt class="text-slate">Enquiries</dt>
|
||||||
<dd>{{ enquiry_count }}</dd>
|
<dd>{{ enquiry_count }}</dd>
|
||||||
<dt class="text-slate">Claimed By</dt>
|
<dt class="text-slate">Claimed By</dt>
|
||||||
<dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd>
|
<dd>{% if supplier.claimed_by %}<a href="{{ url_for('admin.user_detail', user_id=supplier.claimed_by) }}">User #{{ supplier.claimed_by }}</a>{% else %}Unclaimed{% endif %}</dd>
|
||||||
<dt class="text-slate">Created</dt>
|
<dt class="text-slate">Created</dt>
|
||||||
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
Reference in New Issue
Block a user