feat(hosting,prospecting): add hosting unit tests and fix template bugs
All checks were successful
CI / ruff (push) Successful in 10s
CI / pytest (push) Successful in 48m48s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 38s
CI / deploy (push) Successful in 51s

- Add 55 unit tests for hosting module (hosted site service, client
  service service, stats service) with full fixture setup
- Fix table_empty_state macro: add x_message param for dynamic Alpine.js
  expressions rendered via x-text instead of server-side Jinja
- Fix hosting templates (sites, clients) using message= with Alpine
  expressions that rendered as literal text
- Fix prospecting templates (leads, scan-jobs, prospects) using
  nonexistent subtitle= param, migrated to x_message=
- Align hosting and prospecting admin templates with shared design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 06:18:26 +01:00
parent 8136739233
commit 2287f4597d
21 changed files with 2014 additions and 1118 deletions

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Campaigns{% endblock %}
@@ -10,15 +11,22 @@
{{ page_header('Campaign Templates', action_label='New Template', action_onclick='showCreateModal = true', action_icon='plus') }}
<!-- Filter by Lead Type -->
<div class="mb-6 flex flex-wrap gap-2">
<button @click="filterLeadType = ''" :class="!filterLeadType ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-3 py-1.5 text-sm rounded-lg">All</button>
<template x-for="lt in leadTypes" :key="lt.value">
<button @click="filterLeadType = lt.value"
:class="filterLeadType === lt.value ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="px-3 py-1.5 text-sm rounded-lg"
x-text="lt.label"></button>
</template>
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap gap-2">
<button type="button" @click="filterLeadType = ''"
:class="!filterLeadType
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-150">All</button>
<template x-for="lt in leadTypes" :key="lt.value">
<button type="button" @click="filterLeadType = lt.value"
:class="filterLeadType === lt.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-150"
x-text="lt.label"></button>
</template>
</div>
</div>
{{ loading_state('Loading templates...') }}
@@ -26,116 +34,125 @@
<!-- Templates Grid -->
<div x-show="!loading && !error" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Empty State -->
<div x-show="filteredTemplates().length === 0" class="col-span-full py-12 text-center">
<span x-html="$icon('mail', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium text-gray-600 dark:text-gray-400">No templates found</p>
<p class="text-xs text-gray-500 mt-1" x-text="filterLeadType ? 'Try a different lead type filter' : 'Create your first campaign template'"></p>
</div>
<template x-for="tpl in filteredTemplates()" :key="tpl.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow duration-150">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="tpl.name"></h3>
<div class="flex items-center space-x-2 mt-1">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
x-text="tpl.lead_type.replace('_', ' ')"></span>
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="tpl.channel"></span>
<span class="text-xs text-gray-400" x-text="tpl.language.toUpperCase()"></span>
</div>
</div>
<span class="w-2 h-2 rounded-full" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"></span>
<span class="w-2.5 h-2.5 rounded-full mt-1" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"
:title="tpl.is_active ? 'Active' : 'Inactive'"></span>
</div>
<p x-show="tpl.subject_template" class="text-xs text-gray-500 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p>
<p x-show="tpl.subject_template" class="text-xs text-gray-500 dark:text-gray-400 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p>
<p class="text-xs text-gray-400 line-clamp-2" x-text="tpl.body_template"></p>
<div class="flex justify-end mt-3 space-x-2">
<button @click="editTemplate(tpl)" class="text-xs text-purple-600 hover:text-purple-900 dark:text-purple-400">Edit</button>
<button @click="deleteTemplate(tpl.id)" class="text-xs text-red-600 hover:text-red-900 dark:text-red-400">Delete</button>
<div class="flex justify-end mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 space-x-2">
<button type="button" @click="editTemplate(tpl)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit template">
<span x-html="$icon('edit', 'w-4 h-4')"></span>
</button>
<button type="button" @click="deleteTemplate(tpl.id)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete template">
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</div>
</template>
</div>
<!-- Create/Edit Template Modal --> {# noqa: FE-004 #}
<div x-show="showCreateModal || showEditModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false; showEditModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[90vh] overflow-y-auto"
@keydown.escape.window="showCreateModal = false; showEditModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="showEditModal ? 'Edit Template' : 'New Template'"></h3>
<button @click="showCreateModal = false; showEditModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<!-- Create/Edit Template Modal -->
{% call modal('templateModal', 'New Template', show_var='showCreateModal || showEditModal', size='lg', show_footer=false) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="templateForm.name"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
<input type="text" x-model="templateForm.name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Lead Type</label>
<select x-model="templateForm.lead_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<template x-for="lt in leadTypes" :key="lt.value">
<option :value="lt.value" x-text="lt.label"></option>
</template>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="templateForm.channel"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="email">Email</option>
<option value="letter">Letter</option>
<option value="phone_script">Phone Script</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Language</label>
<select x-model="templateForm.language"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="fr">French</option>
<option value="de">German</option>
<option value="en">English</option>
<option value="lb">Luxembourgish</option>
</select>
</div>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
<input type="text" x-model="templateForm.subject_template"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Body</label>
<textarea x-model="templateForm.body_template" rows="10"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea>
<div class="mt-1 flex flex-wrap gap-1">
<span class="text-xs text-gray-400">Placeholders:</span>
<template x-for="ph in placeholders" :key="ph">
<button @click="insertPlaceholder(ph)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="ph"></button>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Lead Type</label>
<select x-model="templateForm.lead_type"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<template x-for="lt in leadTypes" :key="lt.value">
<option :value="lt.value" x-text="lt.label"></option>
</template>
</div>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" x-model="templateForm.is_active" id="tpl-active"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<label for="tpl-active" class="ml-2 text-sm text-gray-600 dark:text-gray-400">Active</label>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Channel</label>
<select x-model="templateForm.channel"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="email">Email</option>
<option value="letter">Letter</option>
<option value="phone_script">Phone Script</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Language</label>
<select x-model="templateForm.language"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="fr">French</option>
<option value="de">German</option>
<option value="en">English</option>
<option value="lb">Luxembourgish</option>
</select>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showCreateModal = false; showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="saveTemplate()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Save
</button>
</footer>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Subject</label>
<input type="text" x-model="templateForm.subject_template"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Body</label>
<textarea x-model="templateForm.body_template" rows="10"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea>
<div class="mt-2 flex flex-wrap gap-1">
<span class="text-xs text-gray-400">Placeholders:</span>
<template x-for="ph in placeholders" :key="ph">
<button type="button" @click="insertPlaceholder(ph)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 px-1 py-0.5 rounded hover:bg-purple-50 dark:hover:bg-gray-700 transition-colors"
x-text="ph"></button>
</template>
</div>
</div>
<div class="flex items-center">
<input type="checkbox" x-model="templateForm.is_active" id="tpl-active"
class="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<label for="tpl-active" class="ml-2 text-sm text-gray-700 dark:text-gray-400">Active</label>
</div>
</div>
</div>
<!-- Footer Actions -->
<div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showCreateModal = false; showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button type="button" @click="saveTemplate()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple transition-colors duration-150">
<span x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-text="showEditModal ? 'Update Template' : 'Create Template'"></span>
</button>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,130 +1,145 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state %}
{% block title %}Quick Capture{% endblock %}
{% block alpine_data %}quickCapture(){% endblock %}
{% block content %}
{{ page_header('Quick Capture') }}
{{ page_header('Quick Capture', subtitle='Capture offline prospects on the go') }}
<div class="max-w-lg mx-auto">
<!-- Success Message -->
<div x-show="saved" x-transition
class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300">
<span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span>
<span x-text="'Prospect saved: ' + lastSaved"></span>
</div>
<!-- Success Message -->
<div x-show="saved" x-transition
class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300 max-w-2xl mx-auto">
<span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span>
<span x-text="'Prospect saved: ' + lastSaved"></span>
</div>
<div class="max-w-2xl mx-auto">
<div class="px-4 py-5 sm:p-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800 space-y-5">
<!-- Business Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Business Name *</label>
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.business_name" required autofocus
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300 py-3">
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"
placeholder="e.g. Boulangerie Schmidt">
</div>
<!-- Phone + Email Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Phone</label>
<input type="tel" x-model="form.phone" placeholder="+352..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Email</label>
<input type="email" x-model="form.email" placeholder="contact@..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Address -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address</label>
<input type="text" x-model="form.address"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Address</label>
<input type="text" x-model="form.address" placeholder="Street and number"
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<!-- City + Postal Code -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">City</label>
<input type="text" x-model="form.city" placeholder="Luxembourg"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label>
<input type="text" x-model="form.postal_code"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Postal Code</label>
<input type="text" x-model="form.postal_code" placeholder="L-1234"
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Source -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Source</label>
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Source</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<template x-for="src in sources" :key="src.value">
<button @click="form.source = src.value"
:class="form.source === src.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'"
class="px-3 py-2 text-sm font-medium rounded-lg border"
<button type="button" @click="form.source = src.value"
:class="form.source === src.value
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors duration-150"
x-text="src.label"></button>
</template>
</div>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Tags</label>
<div class="flex flex-wrap gap-2">
<template x-for="tag in availableTags" :key="tag">
<button @click="toggleTag(tag)"
:class="form.tags.includes(tag) ? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400'"
class="px-3 py-1.5 text-xs font-medium rounded-full border"
<button type="button" @click="toggleTag(tag)"
:class="form.tags.includes(tag)
? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300 dark:border-purple-700'
: 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors duration-150"
x-text="tag"></button>
</template>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="form.notes" rows="3" placeholder="Quick notes..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"></textarea>
class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<!-- Location -->
<div class="flex items-center space-x-3">
<button @click="getLocation()"
<!-- Location + Submit Row -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" @click="getLocation()"
:disabled="gettingLocation"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700 disabled:opacity-50">
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50">
<span x-html="$icon('location-marker', 'w-4 h-4 mr-2')"></span>
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
</button>
<span x-show="form.location_lat" class="text-xs text-green-600">
<span x-show="form.location_lat" class="text-xs text-green-600 dark:text-green-400 self-center">
<span x-text="form.location_lat?.toFixed(4)"></span>, <span x-text="form.location_lng?.toFixed(4)"></span>
</span>
</div>
<!-- Submit -->
<button @click="submitCapture()"
:disabled="submitting || !form.business_name"
class="w-full px-6 py-4 text-lg font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 active:bg-purple-800">
<span x-show="!submitting">Save & Capture Next</span>
<span x-show="submitting">Saving...</span>
</button>
<div class="sm:ml-auto">
<button type="button" @click="submitCapture()"
:disabled="submitting || !form.business_name"
class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!submitting" x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-show="submitting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submitting ? 'Saving...' : 'Save & Capture Next'"></span>
</button>
</div>
</div>
</div>
<!-- Recent Captures -->
<div x-show="recentCaptures.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3">
Recent Captures (<span x-text="recentCaptures.length"></span>)
</h3>
<div class="space-y-2">
<template x-for="cap in recentCaptures" :key="cap.id">
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm dark:bg-gray-800">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span>
<span class="text-xs text-gray-500" x-text="cap.city || cap.source"></span>
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
x-text="cap.business_name.charAt(0).toUpperCase()"></span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="cap.city || cap.source"></span>
</div>
</template>
</div>

View File

@@ -1,5 +1,5 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/headers.html' import page_header, section_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Prospecting Dashboard{% endblock %}
@@ -14,7 +14,7 @@
<div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span>
</div>
@@ -23,7 +23,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_prospects || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('cursor-click', 'w-5 h-5')"></span>
</div>
@@ -32,7 +32,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.top_priority || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
</div>
@@ -41,7 +41,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.avg_score ? stats.avg_score.toFixed(1) : '—'"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('phone', 'w-5 h-5')"></span>
</div>
@@ -53,41 +53,44 @@
</div>
<!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6">
<a href="/admin/prospecting/capture"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('phone', 'w-4 h-4 mr-2')"></span>
Quick Capture
</a>
<button @click="runBatchScan()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span>
Run Batch Scan
</button>
<button @click="showImportModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Import Domains
</button>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Quick Actions', icon='cursor-click') }}
<div class="flex flex-wrap gap-3">
<a href="/admin/prospecting/capture"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('phone', 'w-4 h-4 mr-2')"></span>
Quick Capture
</a>
<button type="button" @click="runBatchScan()"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span>
Run Batch Scan
</button>
<button type="button" @click="showImportModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Import Domains
</button>
</div>
</div>
<!-- Leads by Tier -->
<!-- Leads by Tier + By Channel -->
<div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Leads by Tier</h3>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Leads by Tier', icon='chart-bar') }}
<div class="space-y-3">
<template x-for="tier in ['top_priority', 'quick_win', 'strategic', 'low_priority']" :key="tier">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="tier.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(tier)"
x-text="stats.leads_by_tier?.[tier] || 0"></span>
</div>
</template>
</div>
</div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">By Channel</h3>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('By Channel', icon='globe') }}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Digital (Domain Scan)</span>
@@ -102,21 +105,22 @@
</div>
<!-- Common Issues -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Common Issues</h3>
<div class="space-y-2">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Common Issues', icon='exclamation') }}
<div class="space-y-3">
<template x-for="issue in stats.common_issues || []" :key="issue.flag">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="issueLabel(issue.flag)"></span>
<div class="flex items-center">
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-2">
<div class="h-2 bg-red-500 rounded-full"
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-3">
<div class="h-2 bg-red-500 rounded-full transition-all duration-300"
:style="'width: ' + (issue.count / (stats.total_prospects || 1) * 100) + '%'"></div>
</div>
<span class="text-xs text-gray-500" x-text="issue.count"></span>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-8 text-right" x-text="issue.count"></span>
</div>
</div>
</template>
<p x-show="!stats.common_issues?.length" class="text-sm text-gray-400 text-center py-4">No issues data available</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Leads{% endblock %}
@@ -10,38 +11,39 @@
{% block content %}
{{ page_header('Leads', action_label='Export CSV', action_onclick='exportCSV()', action_icon='download') }}
{{ loading_state('Loading leads...') }}
{{ error_state('Error loading leads') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-5">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Min Score</label>
<input type="number" x-model.number="minScore" @change="loadLeads()" min="0" max="100" {# noqa: FE008 - score filter, not a quantity stepper #}
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Min Score -->
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-400 whitespace-nowrap">Min Score</label>
<input type="number" x-model.number="minScore" @change="pagination.page = 1; loadLeads()" min="0" max="100" {# noqa: FE008 - score filter, not a quantity stepper #}
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
<select x-model="filterTier" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<select x-model="filterTier" @change="pagination.page = 1; loadLeads()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Tiers</option>
<option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="filterChannel" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<select x-model="filterChannel" @change="pagination.page = 1; loadLeads()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Channels</option>
<option value="digital">Digital</option>
<option value="offline">Offline</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Issue</label>
<select x-model="filterIssue" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<select x-model="filterIssue" @change="pagination.page = 1; loadLeads()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Issues</option>
<option value="no_ssl">No SSL</option>
<option value="very_slow">Very Slow</option>
<option value="not_mobile_friendly">Not Mobile Friendly</option>
@@ -49,12 +51,10 @@
<option value="no_website">No Website</option>
<option value="uses_gmail">Uses Gmail</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Has Email</label>
<select x-model="filterHasEmail" @change="loadLeads()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<select x-model="filterHasEmail" @change="pagination.page = 1; loadLeads()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">Has Email</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
@@ -62,64 +62,80 @@
</div>
</div>
{{ loading_state('Loading leads...') }}
{{ error_state('Error loading leads') }}
<!-- Leads Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Issues</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="lead in leads" :key="lead.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<p class="font-semibold text-sm" x-text="lead.business_name || lead.domain_name"></p>
<p class="text-xs text-gray-500" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p>
</td>
<td class="px-4 py-3">
<span class="text-lg font-bold" :class="scoreColor(lead.score)"
x-text="lead.score"></span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="tierBadgeClass(lead.lead_tier)"
x-text="lead.lead_tier?.replace('_', ' ')"></span>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag">
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span>
</template>
<div x-show="!loading && !error">
{% call table_wrapper() %}
{{ table_header(['Business / Domain', 'Score', 'Tier', 'Issues', 'Contact', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No leads found', x_message="filterTier || filterChannel || filterIssue || filterHasEmail || minScore > 0 ? 'Try adjusting your filters' : 'No scored leads yet'", show_condition='leads.length === 0', icon='user-group') }}
<template x-for="lead in leads" :key="lead.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Business / Domain with Avatar -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100"
x-text="(lead.business_name || lead.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span>
</td>
<td class="px-4 py-3 text-sm space-x-2">
<a :href="'/admin/prospecting/prospects/' + lead.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400">View</a>
<button @click="sendCampaign(lead)"
class="text-green-600 hover:text-green-900 dark:text-green-400">Campaign</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div>
<p class="font-semibold" x-text="lead.business_name || lead.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p>
</div>
</div>
</td>
{{ pagination() }}
<!-- Score -->
<td class="px-4 py-3">
<span class="text-lg font-bold" :class="scoreColor(lead.score)"
x-text="lead.score ?? '—'"></span>
</td>
<!-- Tier Badge -->
<td class="px-4 py-3">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(lead.lead_tier)"
x-text="lead.lead_tier?.replace('_', ' ')"></span>
</td>
<!-- Issues -->
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag">
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span>
</template>
</div>
</td>
<!-- Contact -->
<td class="px-4 py-3 text-sm">
<span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a :href="'/admin/prospecting/prospects/' + lead.id"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button @click="sendCampaign(lead)"
class="flex items-center justify-center p-2 text-green-600 rounded-lg hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Send campaign">
<span x-html="$icon('mail', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/headers.html' import detail_page_header, section_header, tab_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Prospect Detail{% endblock %}
@@ -12,128 +13,132 @@
<div x-show="!loading && !error && prospect" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
<div class="flex items-center space-x-4">
<a href="/admin/prospecting/prospects"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Back to prospects">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<div class="relative hidden w-10 h-10 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-100"
x-text="(prospect.business_name || prospect.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="prospect.business_name || prospect.domain_name"></h2>
<p class="text-sm text-gray-500" x-show="prospect.domain_name && prospect.business_name"
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="prospect.domain_name && prospect.business_name"
x-text="prospect.domain_name"></p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="prospect.channel === 'digital' ? 'text-blue-700 bg-blue-100' : 'text-purple-700 bg-purple-100'"
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="prospect.channel === 'digital'
? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700'
: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="prospect.channel"></span>
</div>
<div class="flex items-center space-x-3">
<!-- Score Badge -->
<div x-show="prospect.score" class="text-center">
<div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)"
x-text="prospect.score?.score"></div>
<div class="text-xs text-gray-500 uppercase"
x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div>
</div>
<!-- Score Badge -->
<div x-show="prospect.score" class="text-center">
<div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)"
x-text="prospect.score?.score"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase"
x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div>
</div>
</div>
<!-- Status Bar -->
<div class="flex items-center space-x-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<label class="text-sm text-gray-600 dark:text-gray-400">Status:</label>
<select x-model="prospect.status" @change="updateStatus()"
class="text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="inactive">Inactive</option>
</select>
<button @click="runEnrichment()" x-show="prospect.channel === 'digital'"
class="ml-auto px-3 py-1 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('globe-alt', 'w-4 h-4 inline mr-1')"></span>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-400">Status:</label>
<select x-model="prospect.status" @change="updateStatus()"
class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="inactive">Inactive</option>
</select>
</div>
<button type="button" @click="runEnrichment()" x-show="prospect.channel === 'digital'"
class="sm:ml-auto inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span>
Run Scan
</button>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-8">
<template x-for="tab in tabs" :key="tab.id">
<button @click="activeTab = tab.id"
:class="activeTab === tab.id ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
{{ tab_header([
{'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
{'id': 'interactions', 'label': 'Interactions', 'icon': 'chat'},
{'id': 'campaigns', 'label': 'Campaigns', 'icon': 'mail'},
], active_var='activeTab') }}
<!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<!-- Contact Info -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Contact Info', icon='phone') }}
<template x-for="c in prospect.contacts || []" :key="c.id">
<div class="flex items-center justify-between py-2 border-b dark:border-gray-700 last:border-0">
<span class="text-xs text-gray-500 uppercase" x-text="c.contact_type"></span>
<div class="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" x-text="c.contact_type"></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="c.value"></span>
</div>
</template>
<p x-show="!prospect.contacts?.length" class="text-sm text-gray-400">No contacts found</p>
<p x-show="!prospect.contacts?.length" class="text-sm text-gray-400 text-center py-4">No contacts found</p>
</div>
<!-- Score Breakdown -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Score Breakdown</h3>
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Score Breakdown', icon='chart-bar') }}
<template x-if="prospect.score">
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Technical Health</span>
<span class="font-semibold" x-text="prospect.score.technical_health_score + '/40'"></span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.technical_health_score + '/40'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Modernity</span>
<span class="font-semibold" x-text="prospect.score.modernity_score + '/25'"></span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.modernity_score + '/25'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Business Value</span>
<span class="font-semibold" x-text="prospect.score.business_value_score + '/25'"></span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.business_value_score + '/25'"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Engagement</span>
<span class="font-semibold" x-text="prospect.score.engagement_score + '/10'"></span>
<span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.engagement_score + '/10'"></span>
</div>
<div class="pt-3 border-t dark:border-gray-700">
<h4 class="text-xs font-semibold text-gray-500 uppercase mb-2">Issues</h4>
<div class="pt-3 border-t border-gray-100 dark:border-gray-700">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Issues</h4>
<div class="flex flex-wrap gap-2">
<template x-for="flag in prospect.score.reason_flags || []" :key="flag">
<span class="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300"
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span>
</template>
</div>
</div>
</div>
</template>
<p x-show="!prospect.score" class="text-sm text-gray-400">Not scored yet</p>
<p x-show="!prospect.score" class="text-sm text-gray-400 text-center py-4">Not scored yet</p>
</div>
<!-- Tech Profile Summary -->
<div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Technology</h3>
<div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Technology', icon='code') }}
<div class="space-y-2 text-sm">
<template x-for="[key, val] in techProfileEntries()" :key="key">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400 capitalize" x-text="key.replace('_', ' ')"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="val"></span>
<span class="text-gray-600 dark:text-gray-400" x-text="key"></span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="val"></span>
</div>
</template>
</div>
</div>
<!-- Performance Summary -->
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Performance</h3>
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Performance', icon='chart-bar') }}
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Performance Score</span>
@@ -143,11 +148,12 @@
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span>
<span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600' : 'text-red-600'"></span>
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
class="font-medium"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SEO Score</span>
<span x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
</div>
</div>
</div>
@@ -156,25 +162,25 @@
<!-- Tab: Interactions -->
<div x-show="activeTab === 'interactions'" class="space-y-4">
<div class="flex justify-end">
<button @click="showInteractionModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
<button type="button" @click="showInteractionModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Log Interaction
</button>
</div>
<!-- Timeline -->
<div class="space-y-4">
<template x-for="interaction in interactions" :key="interaction.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 border-l-4"
:class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300'">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border-l-4 transition-shadow hover:shadow-md"
:class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'">
<div class="flex items-center justify-between mb-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
x-text="interaction.interaction_type.replace('_', ' ')"></span>
<span class="text-xs text-gray-500" x-text="new Date(interaction.created_at).toLocaleDateString()"></span>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="new Date(interaction.created_at).toLocaleDateString()"></span>
</div>
<p x-show="interaction.subject" class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="interaction.subject"></p>
<p x-show="interaction.notes" class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="interaction.notes"></p>
<p x-show="interaction.next_action" class="text-xs text-purple-600 mt-2">
<p x-show="interaction.next_action" class="text-xs text-purple-600 dark:text-purple-400 mt-2">
Next: <span x-text="interaction.next_action"></span>
<span x-show="interaction.next_action_date" x-text="' — ' + interaction.next_action_date"></span>
</p>
@@ -187,92 +193,86 @@
<!-- Tab: Campaigns -->
<div x-show="activeTab === 'campaigns'" class="space-y-4">
<div class="flex justify-end">
<button @click="showSendCampaignModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('mail', 'w-4 h-4 inline mr-1')"></span>
<button type="button" @click="showSendCampaignModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Send Campaign
</button>
</div>
<template x-for="send in campaignSends" :key="send.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="send.rendered_subject || 'No subject'"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="send.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'"
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="send.status === 'sent'
? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="send.status"></span>
</div>
<p class="text-xs text-gray-500 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p>
</div>
</template>
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
</div>
</div>
<!-- Interaction Modal --> {# noqa: FE-004 #}
<div x-show="showInteractionModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showInteractionModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showInteractionModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Interaction</h3>
<button @click="showInteractionModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
<select x-model="newInteraction.interaction_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="note">Note</option>
<option value="call">Phone Call</option>
<option value="email_sent">Email Sent</option>
<option value="email_received">Email Received</option>
<option value="meeting">Meeting</option>
<option value="visit">Visit</option>
<option value="proposal_sent">Proposal Sent</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label>
<input type="text" x-model="newInteraction.subject"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
<textarea x-model="newInteraction.notes" rows="3"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Outcome</label>
<select x-model="newInteraction.outcome"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Not specified</option>
<option value="positive">Positive</option>
<option value="neutral">Neutral</option>
<option value="negative">Negative</option>
<option value="no_answer">No Answer</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Next Action</label>
<input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<!-- Interaction Modal -->
{% call modal('interactionModal', 'Log Interaction', show_var='showInteractionModal', size='md', show_footer=false) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Type</label>
<select x-model="newInteraction.interaction_type"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="note">Note</option>
<option value="call">Phone Call</option>
<option value="email_sent">Email Sent</option>
<option value="email_received">Email Received</option>
<option value="meeting">Meeting</option>
<option value="visit">Visit</option>
<option value="proposal_sent">Proposal Sent</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Subject</label>
<input type="text" x-model="newInteraction.subject"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="newInteraction.notes" rows="3"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Outcome</label>
<select x-model="newInteraction.outcome"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="">Not specified</option>
<option value="positive">Positive</option>
<option value="neutral">Neutral</option>
<option value="negative">Negative</option>
<option value="no_answer">No Answer</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Next Action</label>
<input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showInteractionModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="createInteraction()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Save
</button>
</footer>
</div>
</div>
<!-- Footer Actions -->
<div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showInteractionModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button type="button" @click="createInteraction()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple transition-colors duration-150">
<span x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
Save Interaction
</button>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,8 +1,9 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_header, table_empty_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Prospects{% endblock %}
@@ -11,41 +12,46 @@
{% block content %}
{{ page_header('Prospects', action_label='New Prospect', action_onclick='showCreateModal = true', action_icon='plus') }}
<!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
<input type="text" x-model="search" @input.debounce.300ms="loadProspects()"
placeholder="Domain or business name..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300">
{{ loading_state('Loading prospects...') }}
{{ error_state('Error loading prospects') }}
<!-- Search and Filters Bar -->
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-md">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text" x-model="search" @input.debounce.300ms="pagination.page = 1; loadProspects()"
placeholder="Search by domain or business name..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="filterChannel" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<select x-model="filterChannel" @change="pagination.page = 1; loadProspects()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Channels</option>
<option value="digital">Digital</option>
<option value="offline">Offline</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<select x-model="filterStatus" @change="pagination.page = 1; loadProspects()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="contacted">Contacted</option>
<option value="converted">Converted</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label>
<select x-model="filterTier" @change="loadProspects()"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<select x-model="filterTier" @change="pagination.page = 1; loadProspects()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Tiers</option>
<option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option>
@@ -55,170 +61,178 @@
</div>
</div>
{{ loading_state('Loading prospects...') }}
{{ error_state('Error loading prospects') }}
<!-- Prospects Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Channel</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(7, title='No prospects found', show_condition='prospects.length === 0') }}
<template x-for="p in prospects" :key="p.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="p.business_name || p.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p>
<div x-show="!loading && !error">
{% call table_wrapper() %}
{{ table_header(['Business / Domain', 'Channel', 'Status', 'Score', 'Tier', 'Contact', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(7, title='No prospects found', x_message="search || filterChannel || filterStatus || filterTier ? 'Try adjusting your search or filters' : 'Create your first prospect to get started'", show_condition='prospects.length === 0', icon='user-group') }}
<template x-for="p in prospects" :key="p.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Business / Domain with Avatar -->
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100"
x-text="(p.business_name || p.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="p.channel === 'digital' ? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700' : 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="p.channel"></span>
</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="statusBadgeClass(p.status)"
x-text="p.status"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-text="p.score?.score ?? '—'" class="font-semibold"
:class="scoreColor(p.score?.score)"></span>
</td>
<td class="px-4 py-3 text-sm">
<span x-show="p.score?.lead_tier"
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="tierBadgeClass(p.score?.lead_tier)"
x-text="p.score?.lead_tier?.replace('_', ' ')"></span>
<span x-show="!p.score?.lead_tier" class="text-gray-400"></span>
</td>
<td class="px-4 py-3 text-sm">
<template x-if="p.primary_email">
<span class="text-xs" x-text="p.primary_email"></span>
</template>
<span x-show="!p.primary_email" class="text-gray-400"></span>
</td>
<td class="px-4 py-3 text-sm">
<div>
<p class="font-semibold" x-text="p.business_name || p.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p>
</div>
</div>
</td>
<!-- Channel Badge -->
<td class="px-4 py-3 text-sm">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="p.channel === 'digital'
? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700'
: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="p.channel"></span>
</td>
<!-- Status Badge -->
<td class="px-4 py-3 text-sm">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(p.status)"
x-text="p.status"></span>
</td>
<!-- Score -->
<td class="px-4 py-3 text-sm">
<span x-text="p.score?.score ?? '—'" class="font-semibold"
:class="scoreColor(p.score?.score)"></span>
</td>
<!-- Tier Badge -->
<td class="px-4 py-3 text-sm">
<span x-show="p.score?.lead_tier"
class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(p.score?.lead_tier)"
x-text="p.score?.lead_tier?.replace('_', ' ')"></span>
<span x-show="!p.score?.lead_tier" class="text-gray-400"></span>
</td>
<!-- Contact -->
<td class="px-4 py-3 text-sm">
<template x-if="p.primary_email">
<span class="text-xs" x-text="p.primary_email"></span>
</template>
<span x-show="!p.primary_email" class="text-gray-400"></span>
</td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a :href="'/admin/prospecting/prospects/' + p.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300">
View
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
</div>
{{ pagination() }}
<!-- Create Prospect Modal --> {# noqa: FE-004 #}
<div x-show="showCreateModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showCreateModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">New Prospect</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<!-- Channel Toggle -->
<div class="flex mb-4 space-x-2">
<button @click="newProspect.channel = 'digital'"
:class="newProspect.channel === 'digital' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
Digital (Domain)
</button>
<button @click="newProspect.channel = 'offline'"
:class="newProspect.channel === 'offline' ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg">
Offline (Manual)
</button>
</div>
<!-- Digital Fields -->
<div x-show="newProspect.channel === 'digital'" class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain Name</label>
<input type="text" x-model="newProspect.domain_name" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Offline Fields -->
<div x-show="newProspect.channel === 'offline'" class="space-y-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Business Name *</label>
<input type="text" x-model="newProspect.business_name" placeholder="Business name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Phone</label>
<input type="tel" x-model="newProspect.phone" placeholder="+352..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Email</label>
<input type="email" x-model="newProspect.email" placeholder="contact@..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">City</label>
<input type="text" x-model="newProspect.city" placeholder="Luxembourg"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Source</label>
<select x-model="newProspect.source"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="street">Street</option>
<option value="networking_event">Networking Event</option>
<option value="referral">Referral</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label>
<textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
</div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">
Cancel
</button>
<button @click="createProspect()"
:disabled="creating"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<span x-show="!creating">Create</span>
<span x-show="creating">Creating...</span>
</button>
</footer>
</div>
{{ pagination() }}
</div>
<!-- Create Prospect Modal -->
{% call modal('createProspectModal', 'New Prospect', show_var='showCreateModal', size='md', show_footer=false) %}
<!-- Channel Toggle -->
<div class="flex mb-5 space-x-2">
<button type="button" @click="newProspect.channel = 'digital'"
:class="newProspect.channel === 'digital'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
<span x-html="$icon('globe', 'w-4 h-4 inline mr-1')"></span>
Digital (Domain)
</button>
<button type="button" @click="newProspect.channel = 'offline'"
:class="newProspect.channel === 'offline'
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
<span x-html="$icon('office-building', 'w-4 h-4 inline mr-1')"></span>
Offline (Manual)
</button>
</div>
<!-- Digital Fields -->
<div x-show="newProspect.channel === 'digital'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Domain Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="newProspect.domain_name" placeholder="example.lu"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Offline Fields -->
<div x-show="newProspect.channel === 'offline'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="newProspect.business_name" placeholder="Business name"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Phone</label>
<input type="tel" x-model="newProspect.phone" placeholder="+352..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Email</label>
<input type="email" x-model="newProspect.email" placeholder="contact@..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">City</label>
<input type="text" x-model="newProspect.city" placeholder="Luxembourg"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Source</label>
<select x-model="newProspect.source"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="street">Street</option>
<option value="networking_event">Networking Event</option>
<option value="referral">Referral</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
</div>
<!-- Footer Actions -->
<div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button type="button" @click="createProspect()"
:disabled="creating"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150">
<span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
<span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="creating ? 'Creating...' : 'Create Prospect'"></span>
</button>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Scan Jobs{% endblock %}
@@ -11,82 +12,79 @@
{{ page_header('Scan Jobs') }}
<!-- Quick Actions -->
<div class="mb-6 flex flex-wrap gap-3">
<button @click="startBatchJob('http_check')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
HTTP Check
</button>
<button @click="startBatchJob('tech_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
<span x-html="$icon('code', 'w-4 h-4 mr-2')"></span>
Tech Scan
</button>
<button @click="startBatchJob('performance_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Performance Scan
</button>
<button @click="startBatchJob('contact_scrape')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Contact Scrape
</button>
<button @click="startBatchJob('score_compute')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Compute Scores
</button>
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap gap-3">
<button type="button" @click="startBatchJob('http_check')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
HTTP Check
</button>
<button type="button" @click="startBatchJob('tech_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('code', 'w-4 h-4 mr-2')"></span>
Tech Scan
</button>
<button type="button" @click="startBatchJob('performance_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-orange-600 border border-transparent rounded-lg hover:bg-orange-700 focus:outline-none">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Performance Scan
</button>
<button type="button" @click="startBatchJob('contact_scrape')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none">
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Contact Scrape
</button>
<button type="button" @click="startBatchJob('score_compute')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Compute Scores
</button>
</div>
</div>
{{ loading_state('Loading scan jobs...') }}
{{ error_state('Error loading scan jobs') }}
<!-- Jobs Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-nowrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Job Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Progress</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Result</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm font-semibold" x-text="job.job_type.replace('_', ' ')"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="jobStatusClass(job.status)"
x-text="job.status"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 bg-blue-500 rounded-full" :style="'width: ' + (job.progress_percent || 0) + '%'"></div>
</div>
<span class="text-xs" x-text="job.processed_items + '/' + job.total_items"></span>
<div x-show="!loading && !error">
{% call table_wrapper() %}
{{ table_header(['Job Type', 'Status', 'Progress', 'Started', 'Duration', 'Result']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No scan jobs found', x_message="'Run a batch job above to get started'", show_condition='jobs.length === 0', icon='code') }}
<template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3 text-sm font-semibold capitalize" x-text="job.job_type.replace(/_/g, ' ')"></td>
<td class="px-4 py-3">
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="jobStatusClass(job.status)"
x-text="job.status"></span>
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2">
<div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 rounded-full transition-all duration-300"
:class="job.status === 'completed' ? 'bg-green-500' : job.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'"
:style="'width: ' + (job.progress_percent || 0) + '%'"></div>
</div>
</td>
<td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td>
<td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td>
<td class="px-4 py-3 text-xs">
<span x-show="job.failed_items > 0" class="text-red-600" x-text="job.failed_items + ' failed'"></span>
<span x-show="job.skipped_items > 0" class="text-yellow-600 ml-1" x-text="job.skipped_items + ' skipped'"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<span class="text-xs text-gray-500" x-text="job.processed_items + '/' + job.total_items"></span>
</div>
</td>
<td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td>
<td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td>
<td class="px-4 py-3 text-xs">
<div class="flex items-center space-x-2">
<span x-show="job.failed_items > 0" class="text-red-600 dark:text-red-400" x-text="job.failed_items + ' failed'"></span>
<span x-show="job.skipped_items > 0" class="text-yellow-600 dark:text-yellow-400" x-text="job.skipped_items + ' skipped'"></span>
<span x-show="!job.failed_items && !job.skipped_items && job.status === 'completed'" class="text-green-600 dark:text-green-400">OK</span>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
{{ pagination() }}
</div>
{% endblock %}
{% block extra_scripts %}