feat(prospecting): add complete prospecting module for lead discovery and scoring
Some checks failed
Some checks failed
Migrates scanning pipeline from marketing-.lu-domains app into Orion module. Supports digital (domain scan) and offline (manual capture) lead channels with enrichment, scoring, campaign management, and interaction tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
137
app/modules/prospecting/static/admin/js/campaigns.js
Normal file
137
app/modules/prospecting/static/admin/js/campaigns.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// static/admin/js/campaigns.js
|
||||
|
||||
const campLog = window.LogConfig.createLogger('prospecting-campaigns');
|
||||
|
||||
function campaignManager() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'campaigns',
|
||||
|
||||
templates: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
filterLeadType: '',
|
||||
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
editingTemplateId: null,
|
||||
|
||||
templateForm: {
|
||||
name: '',
|
||||
lead_type: 'no_website',
|
||||
channel: 'email',
|
||||
language: 'fr',
|
||||
subject_template: '',
|
||||
body_template: '',
|
||||
is_active: true,
|
||||
},
|
||||
|
||||
leadTypes: [
|
||||
{ value: 'no_website', label: 'No Website' },
|
||||
{ value: 'bad_website', label: 'Bad Website' },
|
||||
{ value: 'gmail_only', label: 'Gmail Only' },
|
||||
{ value: 'security_issues', label: 'Security Issues' },
|
||||
{ value: 'performance_issues', label: 'Performance Issues' },
|
||||
{ value: 'outdated_cms', label: 'Outdated CMS' },
|
||||
{ value: 'general', label: 'General' },
|
||||
],
|
||||
|
||||
placeholders: [
|
||||
'{business_name}', '{domain}', '{score}', '{issues}',
|
||||
'{primary_email}', '{primary_phone}', '{city}',
|
||||
],
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._campaignsInit) return;
|
||||
window._campaignsInit = true;
|
||||
|
||||
campLog.info('Campaign manager initializing');
|
||||
await this.loadTemplates();
|
||||
},
|
||||
|
||||
async loadTemplates() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/prospecting/campaigns/templates');
|
||||
this.templates = response.items || response || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
campLog.error('Failed to load templates', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filteredTemplates() {
|
||||
if (!this.filterLeadType) return this.templates;
|
||||
return this.templates.filter(t => t.lead_type === this.filterLeadType);
|
||||
},
|
||||
|
||||
editTemplate(tpl) {
|
||||
this.editingTemplateId = tpl.id;
|
||||
this.templateForm = {
|
||||
name: tpl.name,
|
||||
lead_type: tpl.lead_type,
|
||||
channel: tpl.channel,
|
||||
language: tpl.language,
|
||||
subject_template: tpl.subject_template || '',
|
||||
body_template: tpl.body_template,
|
||||
is_active: tpl.is_active,
|
||||
};
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
async saveTemplate() {
|
||||
try {
|
||||
if (this.showEditModal && this.editingTemplateId) {
|
||||
await apiClient.put(
|
||||
'/admin/prospecting/campaigns/templates/' + this.editingTemplateId,
|
||||
this.templateForm,
|
||||
);
|
||||
Utils.showToast('Template updated', 'success');
|
||||
} else {
|
||||
await apiClient.post('/admin/prospecting/campaigns/templates', this.templateForm);
|
||||
Utils.showToast('Template created', 'success');
|
||||
}
|
||||
this.showCreateModal = false;
|
||||
this.showEditModal = false;
|
||||
this.resetForm();
|
||||
await this.loadTemplates();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
try {
|
||||
await apiClient.delete('/admin/prospecting/campaigns/templates/' + id);
|
||||
Utils.showToast('Template deleted', 'success');
|
||||
await this.loadTemplates();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
insertPlaceholder(ph) {
|
||||
this.templateForm.body_template += ph;
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.editingTemplateId = null;
|
||||
this.templateForm = {
|
||||
name: '',
|
||||
lead_type: 'no_website',
|
||||
channel: 'email',
|
||||
language: 'fr',
|
||||
subject_template: '',
|
||||
body_template: '',
|
||||
is_active: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
153
app/modules/prospecting/static/admin/js/capture.js
Normal file
153
app/modules/prospecting/static/admin/js/capture.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// noqa: js-006 - async init pattern is safe, no data loading
|
||||
// static/admin/js/capture.js
|
||||
|
||||
const captureLog = window.LogConfig.createLogger('prospecting-capture');
|
||||
|
||||
function quickCapture() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'capture',
|
||||
|
||||
form: {
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
tags: [],
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
},
|
||||
|
||||
sources: [
|
||||
{ value: 'street', label: 'Street' },
|
||||
{ value: 'networking_event', label: 'Networking' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
],
|
||||
|
||||
availableTags: [
|
||||
'no-website', 'gmail', 'restaurant', 'retail', 'services',
|
||||
'professional', 'construction', 'beauty', 'food',
|
||||
],
|
||||
|
||||
submitting: false,
|
||||
saved: false,
|
||||
lastSaved: '',
|
||||
gettingLocation: false,
|
||||
recentCaptures: [],
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._captureInit) return;
|
||||
window._captureInit = true;
|
||||
|
||||
captureLog.info('Quick capture initializing');
|
||||
},
|
||||
|
||||
toggleTag(tag) {
|
||||
const idx = this.form.tags.indexOf(tag);
|
||||
if (idx >= 0) {
|
||||
this.form.tags.splice(idx, 1);
|
||||
} else {
|
||||
this.form.tags.push(tag);
|
||||
}
|
||||
},
|
||||
|
||||
getLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
Utils.showToast('Geolocation not supported', 'error');
|
||||
return;
|
||||
}
|
||||
this.gettingLocation = true;
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this.form.location_lat = pos.coords.latitude;
|
||||
this.form.location_lng = pos.coords.longitude;
|
||||
this.gettingLocation = false;
|
||||
captureLog.info('Location acquired', pos.coords);
|
||||
},
|
||||
(err) => {
|
||||
this.gettingLocation = false;
|
||||
Utils.showToast('Location error: ' + err.message, 'error');
|
||||
captureLog.error('Geolocation error', err);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
);
|
||||
},
|
||||
|
||||
async submitCapture() {
|
||||
if (!this.form.business_name) {
|
||||
Utils.showToast('Business name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
try {
|
||||
const payload = {
|
||||
channel: 'offline',
|
||||
business_name: this.form.business_name,
|
||||
source: this.form.source,
|
||||
notes: this.form.notes,
|
||||
tags: this.form.tags.length > 0 ? this.form.tags : null,
|
||||
address: this.form.address || null,
|
||||
city: this.form.city || null,
|
||||
postal_code: this.form.postal_code || null,
|
||||
location_lat: this.form.location_lat,
|
||||
location_lng: this.form.location_lng,
|
||||
contacts: [],
|
||||
};
|
||||
|
||||
if (this.form.phone) {
|
||||
payload.contacts.push({ contact_type: 'phone', value: this.form.phone });
|
||||
}
|
||||
if (this.form.email) {
|
||||
payload.contacts.push({ contact_type: 'email', value: this.form.email });
|
||||
}
|
||||
|
||||
const result = await apiClient.post('/admin/prospecting/prospects', payload);
|
||||
|
||||
this.lastSaved = this.form.business_name;
|
||||
this.recentCaptures.unshift({
|
||||
id: result.id,
|
||||
business_name: this.form.business_name,
|
||||
city: this.form.city,
|
||||
source: this.form.source,
|
||||
});
|
||||
|
||||
this.saved = true;
|
||||
setTimeout(() => { this.saved = false; }, 3000);
|
||||
|
||||
this.resetForm();
|
||||
Utils.showToast('Prospect saved!', 'success');
|
||||
captureLog.info('Capture saved', result.id);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
captureLog.error('Capture failed', err);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.form = {
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
city: this.form.city, // Keep city for rapid captures in same area
|
||||
postal_code: this.form.postal_code,
|
||||
source: this.form.source,
|
||||
notes: '',
|
||||
tags: [],
|
||||
location_lat: this.form.location_lat, // Keep GPS
|
||||
location_lng: this.form.location_lng,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
84
app/modules/prospecting/static/admin/js/dashboard.js
Normal file
84
app/modules/prospecting/static/admin/js/dashboard.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// static/admin/js/dashboard.js
|
||||
|
||||
const dashLog = window.LogConfig.createLogger('prospecting-dashboard');
|
||||
|
||||
function prospectingDashboard() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospecting-dashboard',
|
||||
|
||||
stats: {
|
||||
total_prospects: 0,
|
||||
digital_count: 0,
|
||||
offline_count: 0,
|
||||
top_priority: 0,
|
||||
avg_score: null,
|
||||
leads_by_tier: {},
|
||||
common_issues: [],
|
||||
},
|
||||
loading: true,
|
||||
error: null,
|
||||
showImportModal: false,
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectingDashboardInit) return;
|
||||
window._prospectingDashboardInit = true;
|
||||
|
||||
dashLog.info('Dashboard initializing');
|
||||
await this.loadStats();
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await apiClient.get('/admin/prospecting/stats');
|
||||
Object.assign(this.stats, response);
|
||||
dashLog.info('Stats loaded', this.stats);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
dashLog.error('Failed to load stats', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async runBatchScan() {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/http-check/batch');
|
||||
Utils.showToast('Batch scan started', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed to start batch scan: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
issueLabel(flag) {
|
||||
const labels = {
|
||||
no_ssl: 'No SSL/HTTPS',
|
||||
very_slow: 'Very Slow',
|
||||
slow: 'Slow',
|
||||
not_mobile_friendly: 'Not Mobile Friendly',
|
||||
outdated_cms: 'Outdated CMS',
|
||||
unknown_cms: 'Unknown CMS',
|
||||
legacy_js: 'Legacy JavaScript',
|
||||
no_analytics: 'No Analytics',
|
||||
no_website: 'No Website',
|
||||
uses_gmail: 'Uses Gmail',
|
||||
};
|
||||
return labels[flag] || flag.replace(/_/g, ' ');
|
||||
},
|
||||
};
|
||||
}
|
||||
107
app/modules/prospecting/static/admin/js/leads.js
Normal file
107
app/modules/prospecting/static/admin/js/leads.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// static/admin/js/leads.js
|
||||
|
||||
const leadsLog = window.LogConfig.createLogger('prospecting-leads');
|
||||
|
||||
function leadsList() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'leads',
|
||||
|
||||
leads: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Filters
|
||||
minScore: 0,
|
||||
filterTier: '',
|
||||
filterChannel: '',
|
||||
filterIssue: '',
|
||||
filterHasEmail: '',
|
||||
|
||||
// Pagination
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._leadsListInit) return;
|
||||
window._leadsListInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
leadsLog.info('Leads list initializing');
|
||||
await this.loadLeads();
|
||||
},
|
||||
|
||||
async loadLeads() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
if (this.minScore > 0) params.set('min_score', this.minScore);
|
||||
if (this.filterTier) params.set('lead_tier', this.filterTier);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
if (this.filterIssue) params.set('reason_flag', this.filterIssue);
|
||||
if (this.filterHasEmail) params.set('has_email', this.filterHasEmail);
|
||||
|
||||
const response = await apiClient.get('/admin/prospecting/leads?' + params);
|
||||
this.leads = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
leadsLog.error('Failed to load leads', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async exportCSV() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.minScore > 0) params.set('min_score', this.minScore);
|
||||
if (this.filterTier) params.set('lead_tier', this.filterTier);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
|
||||
const url = '/admin/prospecting/leads/export/csv?' + params;
|
||||
window.open('/api/v1' + url, '_blank');
|
||||
Utils.showToast('CSV export started', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Export failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
sendCampaign(lead) {
|
||||
window.location.href = '/admin/prospecting/prospects/' + lead.id + '#campaigns';
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadLeads();
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
};
|
||||
}
|
||||
141
app/modules/prospecting/static/admin/js/prospect-detail.js
Normal file
141
app/modules/prospecting/static/admin/js/prospect-detail.js
Normal file
@@ -0,0 +1,141 @@
|
||||
// static/admin/js/prospect-detail.js
|
||||
|
||||
const detailLog = window.LogConfig.createLogger('prospecting-detail');
|
||||
|
||||
function prospectDetail(prospectId) {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospects',
|
||||
prospectId: prospectId,
|
||||
prospect: null,
|
||||
interactions: [],
|
||||
campaignSends: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
activeTab: 'overview',
|
||||
tabs: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'interactions', label: 'Interactions' },
|
||||
{ id: 'campaigns', label: 'Campaigns' },
|
||||
],
|
||||
|
||||
// Interaction modal
|
||||
showInteractionModal: false,
|
||||
newInteraction: {
|
||||
interaction_type: 'note',
|
||||
subject: '',
|
||||
notes: '',
|
||||
outcome: '',
|
||||
next_action: '',
|
||||
},
|
||||
|
||||
// Campaign modal
|
||||
showSendCampaignModal: false,
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectDetailInit) return;
|
||||
window._prospectDetailInit = true;
|
||||
|
||||
detailLog.info('Prospect detail initializing for ID:', this.prospectId);
|
||||
await this.loadProspect();
|
||||
},
|
||||
|
||||
async loadProspect() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.prospect = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId);
|
||||
await Promise.all([
|
||||
this.loadInteractions(),
|
||||
this.loadCampaignSends(),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
detailLog.error('Failed to load prospect', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadInteractions() {
|
||||
try {
|
||||
const resp = await apiClient.get('/admin/prospecting/prospects/' + this.prospectId + '/interactions');
|
||||
this.interactions = resp.items || resp || [];
|
||||
} catch (err) {
|
||||
detailLog.warn('Failed to load interactions', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadCampaignSends() {
|
||||
try {
|
||||
const resp = await apiClient.get('/admin/prospecting/campaigns/sends?prospect_id=' + this.prospectId);
|
||||
this.campaignSends = resp.items || resp || [];
|
||||
} catch (err) {
|
||||
detailLog.warn('Failed to load campaign sends', err);
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatus() {
|
||||
try {
|
||||
await apiClient.put('/admin/prospecting/prospects/' + this.prospectId, {
|
||||
status: this.prospect.status,
|
||||
});
|
||||
Utils.showToast('Status updated', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runEnrichment() {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/full/' + this.prospectId);
|
||||
Utils.showToast('Enrichment scan started', 'success');
|
||||
setTimeout(() => this.loadProspect(), 5000);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createInteraction() {
|
||||
try {
|
||||
await apiClient.post(
|
||||
'/admin/prospecting/prospects/' + this.prospectId + '/interactions',
|
||||
this.newInteraction,
|
||||
);
|
||||
Utils.showToast('Interaction logged', 'success');
|
||||
this.showInteractionModal = false;
|
||||
this.newInteraction = { interaction_type: 'note', subject: '', notes: '', outcome: '', next_action: '' };
|
||||
await this.loadInteractions();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
|
||||
techProfileEntries() {
|
||||
const tp = this.prospect?.tech_profile;
|
||||
if (!tp) return [];
|
||||
const entries = [];
|
||||
if (tp.cms) entries.push(['CMS', tp.cms + (tp.cms_version ? ' ' + tp.cms_version : '')]);
|
||||
if (tp.server) entries.push(['Server', tp.server]);
|
||||
if (tp.js_framework) entries.push(['JS Framework', tp.js_framework]);
|
||||
if (tp.analytics) entries.push(['Analytics', tp.analytics]);
|
||||
if (tp.ecommerce_platform) entries.push(['E-commerce', tp.ecommerce_platform]);
|
||||
if (tp.hosting_provider) entries.push(['Hosting', tp.hosting_provider]);
|
||||
if (tp.cdn) entries.push(['CDN', tp.cdn]);
|
||||
entries.push(['SSL Valid', tp.has_valid_cert ? 'Yes' : 'No']);
|
||||
return entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
149
app/modules/prospecting/static/admin/js/prospects.js
Normal file
149
app/modules/prospecting/static/admin/js/prospects.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// static/admin/js/prospects.js
|
||||
|
||||
const prospectsLog = window.LogConfig.createLogger('prospecting-prospects');
|
||||
|
||||
function prospectsList() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'prospects',
|
||||
|
||||
prospects: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
// Filters
|
||||
search: '',
|
||||
filterChannel: '',
|
||||
filterStatus: '',
|
||||
filterTier: '',
|
||||
|
||||
// Pagination
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
// Create modal
|
||||
showCreateModal: false,
|
||||
creating: false,
|
||||
newProspect: {
|
||||
channel: 'digital',
|
||||
domain_name: '',
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
},
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._prospectsListInit) return;
|
||||
window._prospectsListInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
prospectsLog.info('Prospects list initializing');
|
||||
await this.loadProspects();
|
||||
},
|
||||
|
||||
async loadProspects() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
if (this.search) params.set('search', this.search);
|
||||
if (this.filterChannel) params.set('channel', this.filterChannel);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
if (this.filterTier) params.set('tier', this.filterTier);
|
||||
|
||||
const response = await apiClient.get('/admin/prospecting/prospects?' + params);
|
||||
this.prospects = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
prospectsLog.error('Failed to load prospects', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async createProspect() {
|
||||
this.creating = true;
|
||||
try {
|
||||
const payload = { ...this.newProspect };
|
||||
if (payload.channel === 'digital') {
|
||||
delete payload.business_name;
|
||||
delete payload.phone;
|
||||
delete payload.email;
|
||||
delete payload.city;
|
||||
} else {
|
||||
delete payload.domain_name;
|
||||
}
|
||||
await apiClient.post('/admin/prospecting/prospects', payload);
|
||||
Utils.showToast('Prospect created', 'success');
|
||||
this.showCreateModal = false;
|
||||
this.resetNewProspect();
|
||||
await this.loadProspects();
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetNewProspect() {
|
||||
this.newProspect = {
|
||||
channel: 'digital',
|
||||
domain_name: '',
|
||||
business_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
source: 'street',
|
||||
notes: '',
|
||||
};
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadProspects();
|
||||
},
|
||||
|
||||
statusBadgeClass(status) {
|
||||
const classes = {
|
||||
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
||||
active: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
|
||||
contacted: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
converted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
|
||||
inactive: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
error: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
};
|
||||
return classes[status] || classes.pending;
|
||||
},
|
||||
|
||||
tierBadgeClass(tier) {
|
||||
const classes = {
|
||||
top_priority: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
quick_win: 'text-orange-700 bg-orange-100 dark:text-orange-100 dark:bg-orange-700',
|
||||
strategic: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
low_priority: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[tier] || classes.low_priority;
|
||||
},
|
||||
|
||||
scoreColor(score) {
|
||||
if (score == null) return 'text-gray-400';
|
||||
if (score >= 70) return 'text-red-600';
|
||||
if (score >= 50) return 'text-orange-600';
|
||||
if (score >= 30) return 'text-blue-600';
|
||||
return 'text-gray-600';
|
||||
},
|
||||
};
|
||||
}
|
||||
87
app/modules/prospecting/static/admin/js/scan-jobs.js
Normal file
87
app/modules/prospecting/static/admin/js/scan-jobs.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// static/admin/js/scan-jobs.js
|
||||
|
||||
const jobsLog = window.LogConfig.createLogger('prospecting-scan-jobs');
|
||||
|
||||
function scanJobs() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
currentPage: 'scan-jobs',
|
||||
|
||||
jobs: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
|
||||
|
||||
async init() {
|
||||
await I18n.loadModule('prospecting');
|
||||
|
||||
if (window._scanJobsInit) return;
|
||||
window._scanJobsInit = true;
|
||||
|
||||
if (window.PlatformSettings) {
|
||||
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
|
||||
}
|
||||
|
||||
jobsLog.info('Scan jobs initializing');
|
||||
await this.loadJobs();
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.pagination.page,
|
||||
per_page: this.pagination.per_page,
|
||||
});
|
||||
const response = await apiClient.get('/admin/prospecting/stats/jobs?' + params);
|
||||
this.jobs = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
jobsLog.error('Failed to load jobs', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async startBatchJob(jobType) {
|
||||
try {
|
||||
await apiClient.post('/admin/prospecting/enrichment/' + jobType.replace('_', '-') + '/batch');
|
||||
Utils.showToast(jobType.replace(/_/g, ' ') + ' batch started', 'success');
|
||||
setTimeout(() => this.loadJobs(), 2000);
|
||||
} catch (err) {
|
||||
Utils.showToast('Failed: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
this.pagination.page = page;
|
||||
this.loadJobs();
|
||||
},
|
||||
|
||||
jobStatusClass(status) {
|
||||
const classes = {
|
||||
pending: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
|
||||
running: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
|
||||
completed: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
|
||||
failed: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
|
||||
cancelled: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700',
|
||||
};
|
||||
return classes[status] || classes.pending;
|
||||
},
|
||||
|
||||
formatDuration(job) {
|
||||
if (!job.started_at) return '—';
|
||||
const start = new Date(job.started_at);
|
||||
const end = job.completed_at ? new Date(job.completed_at) : new Date();
|
||||
const seconds = Math.round((end - start) / 1000);
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins + 'm ' + secs + 's';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user