add quick leads
All checks were successful
Build and Publish / build-release (push) Successful in 4m11s

This commit is contained in:
2026-05-04 13:06:09 -05:00
parent 53bbdca525
commit f19a727ef0
4 changed files with 316 additions and 126 deletions

View File

@@ -188,7 +188,7 @@ const customerPolicies = computed(() => policiesData.value?.data ?? [])
<span>Customer Information</span>
</div>
<div class="p-4 text-sm text-gray-600">
<p>This customer has {{ customerPolicies.length }} policy{{ customerPolicies.length !== 1 ? 'ies' : '' }} on file.</p>
<p>This customer has {{ customerPolicies.length }} polic{{ customerPolicies.length === 1 ? 'y' : 'ies' }} on file.</p>
</div>
</div>
</div>

View File

@@ -17,11 +17,22 @@ const emails = ref<Record<string, string>>({
const roles = ['quotes', 'claims', 'renewals', 'billing', 'support']
const label = computed(() => {
const getProviderLabel = computed(() => {
if (!provider.value) return ''
return provider.value.name || 'Unknown'
})
const getRoleLabel = (role: string) => {
const labels: Record<string, string> = {
quotes: 'Quotes',
claims: 'Claims',
renewals: 'Renewals',
billing: 'Billing',
support: 'Support'
}
return labels[role] || role
}
// templates and default_templates come directly from provider
const templates = computed(() => provider.value?.templates ?? {})
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
@@ -81,7 +92,7 @@ async function toggleTemplate(templateId: string, active: boolean, policyType: s
try {
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
method: 'POST',
body: { policy_type: policyType, client_type: client_type }
body: { policy_type: policyType, client_type: clientType }
})
toast.add({ title: `Template ${active ? 'deactivated' : 'activated'}`, color: 'green' })
await refresh()
@@ -182,7 +193,7 @@ const clientTypeColor = (ct: string) =>
from these slots.
</p>
<div class="grid gap-3 sm:grid-cols-2">
<UFormField v-for="role in roles" :key="role" :label="label(role)">
<UFormField v-for="role in roles" :key="role" :label="getRoleLabel(role)">
<UInput v-model="emails[role]" type="email" placeholder="name@carrier.com" class="w-full" />
</UFormField>
</div>

View File

@@ -9,62 +9,91 @@ interface QuickLead {
name: string
phone: string
email: string
product: string
source: string
priority: 'normal' | 'high' | 'urgent'
note: string
agent: string
createdAt: string
}
/* ── Storage ── */
const STORAGE_KEY = 'policy-ui.quick-leads'
function loadLeads(): QuickLead[] {
if (import.meta.client) {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
return JSON.parse(raw) as QuickLead[]
}
} catch {
return []
}
}
return []
}
function saveLeads(leads: QuickLead[]) {
if (import.meta.client) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(leads))
} catch { /* quota */ }
}
notes: string
priority: 'low' | 'medium' | 'high'
source: 'website' | 'referral' | 'social_media' | 'cold_call' | 'email_campaign' | 'other'
status: 'new' | 'contacted' | 'qualified' | 'proposal' | 'negotiation' | 'converted' | 'lost'
assigned_to?: string
company_name?: string
estimated_value?: string
expected_close_date?: string
inserted_at: string
updated_at: string
status_history?: any[]
}
/* ── State ── */
const leads = ref<QuickLead[]>(loadLeads())
const page = ref(1)
const pageSize = ref(100)
watch(leads, (v) => {
saveLeads(v)
}, { deep: true })
/* ── List filtering ── */
type ListFilter = 'all' | 'high' | 'medium' | 'low'
const activeFilter = ref<ListFilter>('all')
function addLead(lead: Omit<QuickLead, 'id' | 'createdAt'>) {
const newLead: QuickLead = {
id: crypto.randomUUID?.() ?? String(Date.now() + Math.random()),
createdAt: new Date().toISOString(),
...lead,
// Fetch leads from API
const { data: leadsData, pending: leadsPending, refresh: refreshLeads } = useCustomer('/leads', {
query: computed(() => {
const filters: Record<string, string> = {}
let i = 0
if (activeFilter.value !== 'all') {
filters[`filters[${i}][field]`] = 'priority'
filters[`filters[${i}][op]`] = '=='
filters[`filters[${i}][value]`] = activeFilter.value
i++
}
return {
page: page.value,
page_size: pageSize.value,
...filters
}
})
})
const leads = computed(() => leadsData.value?.data ?? [])
const meta = computed(() => leadsData.value?.meta)
// Create lead function
async function createLead(leadData: Partial<QuickLead>) {
try {
await $fetch('/api/v1/leads', {
baseURL: 'https://dev.api.corredorconect.com/customer',
method: 'POST',
body: {
name: leadData.name,
phone: leadData.phone,
email: leadData.email,
notes: leadData.notes,
priority: leadData.priority,
source: leadData.source,
status: 'new',
assigned_to: leadData.assigned_to,
company_name: leadData.company_name,
estimated_value: leadData.estimated_value,
expected_close_date: leadData.expected_close_date
}
})
await refreshLeads()
} catch (error) {
console.error('Failed to create lead:', error)
throw error
}
leads.value = [newLead, ...leads.value]
}
function removeLead(id: string) {
leads.value = leads.value.filter(l => l.id !== id)
}
function recentLeads(days: number): QuickLead[] {
const cutoff = Date.now() - days * 86400000
return leads.value.filter(l => new Date(l.createdAt).getTime() > cutoff)
// Update lead status function
async function updateLeadStatus(id: string, status: QuickLead['status']) {
try {
await $fetch(`/api/v1/leads/${id}/status`, {
baseURL: 'https://dev.api.corredorconect.com/customer',
method: 'PUT',
body: { status }
})
await refreshLeads()
} catch (error) {
console.error('Failed to update lead status:', error)
throw error
}
}
/* ── Form state ── */
@@ -72,69 +101,109 @@ const formOpen = ref(false)
const name = ref('')
const phone = ref('')
const email = ref('')
const product = ref('')
const notes = ref('')
const priority = ref<'low' | 'medium' | 'high'>('low')
const source = ref('')
const priority = ref<'normal' | 'high' | 'urgent'>('normal')
const note = ref('')
const productOptions = [
{ label: 'Auto', value: 'Auto' },
{ label: 'Health', value: 'Health' },
{ label: 'Life', value: 'Life' },
{ label: 'General Risk', value: 'General Risk' },
{ label: 'Custom', value: 'Custom' },
]
const assigned_to = ref('')
const company_name = ref('')
const estimated_value = ref('')
const expected_close_date = ref('')
const sourceOptions = [
{ label: 'Walk-in', value: 'walk-in' },
{ label: 'Referral', value: 'referral' },
{ label: 'Phone call', value: 'phone' },
{ label: 'Website', value: 'website' },
{ label: 'Social media', value: 'social' },
{ label: 'Referral', value: 'referral' },
{ label: 'Social media', value: 'social_media' },
{ label: 'Cold call', value: 'cold_call' },
{ label: 'Email campaign', value: 'email_campaign' },
{ label: 'Other', value: 'other' },
]
const priorityOptions = [
{ label: 'Normal', value: 'normal' as const },
{ label: 'High — follow up today', value: 'high' as const },
{ label: 'Urgent — client waiting', value: 'urgent' as const },
{ label: 'Low', value: 'low' as const },
{ label: 'Medium', value: 'medium' as const },
{ label: 'High', value: 'high' as const },
]
function submit() {
if (!name.value.trim() || !product.value) return
addLead({
name: name.value.trim(),
phone: phone.value.trim(),
email: email.value.trim(),
product: product.value,
source: source.value || 'other',
priority: priority.value,
note: note.value.trim(),
agent: 'Me', // placeholder
})
toast.add({ title: 'Lead captured', description: `${name.value} added to quick leads`, color: 'success' })
resetForm()
const statusOptions = [
{ label: 'New', value: 'new' },
{ label: 'Contacted', value: 'contacted' },
{ label: 'Qualified', value: 'qualified' },
{ label: 'Proposal', value: 'proposal' },
{ label: 'Negotiation', value: 'negotiation' },
{ label: 'Converted', value: 'converted' },
{ label: 'Lost', value: 'lost' },
]
async function submit() {
if (!name.value.trim()) return
try {
await createLead({
name: name.value.trim(),
phone: phone.value.trim(),
email: email.value.trim(),
notes: notes.value.trim(),
priority: priority.value,
source: source.value || 'other',
assigned_to: assigned_to.value.trim() || undefined,
company_name: company_name.value.trim() || undefined,
estimated_value: estimated_value.value.trim() || undefined,
expected_close_date: expected_close_date.value || undefined,
})
toast.add({ title: 'Lead captured', description: `${name.value} added to quick leads`, color: 'success' })
resetForm()
} catch (error) {
toast.add({ title: 'Failed to create lead', description: 'Please try again', color: 'error' })
}
}
function resetForm() {
name.value = ''
phone.value = ''
email.value = ''
product.value = ''
notes.value = ''
priority.value = 'low'
source.value = ''
priority.value = 'normal'
note.value = ''
assigned_to.value = ''
company_name.value = ''
estimated_value.value = ''
expected_close_date.value = ''
formOpen.value = false
}
function confirmRemove(id: string) {
removeLead(id)
toast.add({ title: 'Lead removed', color: 'neutral' })
async function handleStatusChange(leadId: string, newStatus: QuickLead['status']) {
try {
await updateLeadStatus(leadId, newStatus)
toast.add({ title: 'Status updated', description: `Lead status changed to ${newStatus}`, color: 'success' })
} catch (error) {
toast.add({ title: 'Failed to update status', description: 'Please try again', color: 'error' })
}
}
/* ── List filtering ── */
type ListFilter = 'all' | 'urgent' | 'high' | 'normal'
const activeFilter = ref<ListFilter>('all')
/* ── Drag and drop for status ── */
const draggedLead = ref<string | null>(null)
const draggedOverStatus = ref<QuickLead['status'] | null>(null)
function handleDragStart(leadId: string) {
draggedLead.value = leadId
}
function handleDragOver(status: QuickLead['status']) {
draggedOverStatus.value = status
}
function handleDragEnd() {
if (draggedLead.value && draggedOverStatus.value) {
handleStatusChange(draggedLead.value, draggedOverStatus.value)
}
draggedLead.value = null
draggedOverStatus.value = null
}
function handleDrop(leadId: string, newStatus: QuickLead['status']) {
handleStatusChange(leadId, newStatus)
}
const filteredLeads = computed(() => {
if (activeFilter.value === 'all') return leads.value
@@ -143,16 +212,34 @@ const filteredLeads = computed(() => {
const filterCounts = computed(() => ({
all: leads.value.length,
urgent: leads.value.filter(l => l.priority === 'urgent').length,
high: leads.value.filter(l => l.priority === 'high').length,
normal: leads.value.filter(l => l.priority === 'normal').length,
medium: leads.value.filter(l => l.priority === 'medium').length,
low: leads.value.filter(l => l.priority === 'low').length,
}))
const recentLeads = computed(() => {
const cutoff = Date.now() - 10 * 86400000
return leads.value.filter(l => new Date(l.inserted_at).getTime() > cutoff)
})
/* ── Helpers ── */
function priorityMeta(p: string) {
if (p === 'urgent') return { label: 'Urgent', class: 'ql-pri-urgent' }
if (p === 'high') return { label: 'High', class: 'ql-pri-high' }
return { label: 'Normal', class: 'ql-pri-normal' }
if (p === 'high') return { label: 'High', class: 'ql-pri-urgent' }
if (p === 'medium') return { label: 'Medium', class: 'ql-pri-high' }
return { label: 'Low', class: 'ql-pri-normal' }
}
function statusMeta(s: string) {
const statusMap: Record<string, { label: string; class: string }> = {
new: { label: 'New', class: 'ql-status-new' },
contacted: { label: 'Contacted', class: 'ql-status-contacted' },
qualified: { label: 'Qualified', class: 'ql-status-qualified' },
proposal: { label: 'Proposal', class: 'ql-status-proposal' },
negotiation: { label: 'Negotiation', class: 'ql-status-negotiation' },
converted: { label: 'Converted', class: 'ql-status-converted' },
lost: { label: 'Lost', class: 'ql-status-lost' },
}
return statusMap[s] || { label: s, class: 'ql-status-new' }
}
function formatDate(iso: string) {
@@ -228,21 +315,33 @@ function formatDate(iso: string) {
<div class="ql-section">
<p class="ql-section-title">Lead info</p>
<div class="ql-fields">
<div class="ql-field">
<label class="ql-label">Product line <span class="ql-required">*</span></label>
<USelect v-model="product" :items="productOptions" placeholder="Select line..." size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Source</label>
<USelect v-model="source" :items="sourceOptions" placeholder="How did they find us?" size="sm" />
</div>
<div class="ql-field ql-field-full">
<div class="ql-field">
<label class="ql-label">Priority</label>
<USelect v-model="priority" :items="priorityOptions" placeholder="Normal" size="sm" />
<USelect v-model="priority" :items="priorityOptions" placeholder="Low" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Assigned to</label>
<UInput v-model="assigned_to" placeholder="Agent name" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Company name</label>
<UInput v-model="company_name" placeholder="Company (optional)" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Estimated value</label>
<UInput v-model="estimated_value" placeholder="$0.00" size="sm" />
</div>
<div class="ql-field">
<label class="ql-label">Expected close date</label>
<UInput v-model="expected_close_date" type="date" size="sm" />
</div>
<div class="ql-field ql-field-full">
<label class="ql-label">Notes</label>
<UTextarea v-model="note" placeholder="Brief context, referral source, or anything useful for follow-up..." size="sm" :rows="2" />
<UTextarea v-model="notes" placeholder="Brief context, referral source, or anything useful for follow-up..." size="sm" :rows="2" />
</div>
</div>
</div>
@@ -255,7 +354,7 @@ function formatDate(iso: string) {
</div>
<div class="flex gap-2">
<button type="button" class="ql-cancel-btn" @click="resetForm">Cancel</button>
<button type="button" class="ql-submit-btn" :class="!name.trim() || !product ? 'ql-btn-disabled' : ''" @click="submit">
<button type="button" class="ql-submit-btn" :class="!name.trim() ? 'ql-btn-disabled' : ''" @click="submit">
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
Add Lead
</button>
@@ -272,15 +371,15 @@ function formatDate(iso: string) {
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Last 10 days</p>
<p class="ql-kpi-value">{{ recentLeads(10).length }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Urgent</p>
<p class="ql-kpi-value" style="color: #c13838;">{{ filterCounts.urgent }}</p>
<p class="ql-kpi-value">{{ recentLeads.length }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">High priority</p>
<p class="ql-kpi-value" style="color: #c27b1a;">{{ filterCounts.high }}</p>
<p class="ql-kpi-value" style="color: #c13838;">{{ filterCounts.high }}</p>
</div>
<div class="ql-kpi">
<p class="ql-kpi-label">Medium priority</p>
<p class="ql-kpi-value" style="color: #c27b1a;">{{ filterCounts.medium }}</p>
</div>
</div>
@@ -290,9 +389,9 @@ function formatDate(iso: string) {
<button
v-for="f in ([
{ id: 'all', label: 'All' },
{ id: 'urgent', label: 'Urgent' },
{ id: 'high', label: 'High' },
{ id: 'normal', label: 'Normal' },
{ id: 'medium', label: 'Medium' },
{ id: 'low', label: 'Low' },
] as { id: ListFilter; label: string }[])"
:key="f.id"
type="button"
@@ -308,7 +407,12 @@ function formatDate(iso: string) {
</div>
<!-- ═══ Leads list ═══ -->
<div v-if="filteredLeads.length === 0" class="ql-empty">
<div v-if="leadsPending" class="ql-empty">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin" style="color: #c0c0bc;" />
<p class="text-[13px] text-[var(--text-muted)] mt-2">Loading leads...</p>
</div>
<div v-else-if="filteredLeads.length === 0" class="ql-empty">
<UIcon name="i-heroicons-bolt" style="width: 32px; height: 32px; color: #c0c0bc;" />
<p class="text-[13px] text-[var(--text-muted)] mt-2">No quick leads yet.</p>
<button type="button" class="ql-add-btn mt-3" @click="formOpen = true">
@@ -333,32 +437,47 @@ function formatDate(iso: string) {
<div class="flex items-center gap-2 flex-wrap">
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ lead.name }}</p>
<span :class="priorityMeta(lead.priority).class">{{ priorityMeta(lead.priority).label }}</span>
<span class="ql-product-tag">{{ lead.product }}</span>
<div
:class="statusMeta(lead.status).class"
class="cursor-pointer hover:opacity-80"
draggable="true"
@dragstart="handleDragStart(lead.id)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver(lead.status)"
@drop="handleDrop(lead.id, lead.status)"
title="Drag to change status"
>
{{ statusMeta(lead.status).label }}
</div>
</div>
<div class="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--text-muted)]">
<span v-if="lead.phone">{{ lead.phone }}</span>
<span v-if="lead.email">{{ lead.email }}</span>
<span>{{ formatDate(lead.createdAt) }}</span>
<span>{{ formatDate(lead.inserted_at) }}</span>
</div>
</div>
<div class="ql-lead-actions">
<UDropdown :items="statusOptions.map(s => ({ label: s.label, click: () => handleStatusChange(lead.id, s.value as QuickLead['status']) }))">
<button type="button" class="ql-action-btn" title="Change status">
<UIcon name="i-heroicons-arrows-pointing-out" style="width: 14px; height: 14px;" />
</button>
</UDropdown>
<NuxtLink :to="`/quotes/new`" title="Start quote">
<button type="button" class="ql-action-btn ql-action-quote">
<UIcon name="i-heroicons-calculator" style="width: 14px; height: 14px;" />
</button>
</NuxtLink>
<button type="button" class="ql-action-btn ql-action-delete" title="Remove" @click="confirmRemove(lead.id)">
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
</button>
</div>
</div>
<div v-if="lead.note" class="ql-lead-note">
<div v-if="lead.notes" class="ql-lead-note">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width: 11px; height: 11px; color: #8a8a86; flex-shrink: 0;" />
<span>{{ lead.note }}</span>
<span>{{ lead.notes }}</span>
</div>
<div class="ql-lead-meta">
<span v-if="lead.source" class="ql-meta-tag">{{ lead.source }}</span>
<span class="ql-meta-tag">{{ lead.agent }}</span>
<span v-if="lead.company_name" class="ql-meta-tag">{{ lead.company_name }}</span>
<span v-if="lead.assigned_to" class="ql-meta-tag">{{ lead.assigned_to }}</span>
<span v-if="lead.estimated_value" class="ql-meta-tag">{{ lead.estimated_value }}</span>
</div>
</div>
</TransitionGroup>
@@ -546,9 +665,35 @@ function formatDate(iso: string) {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
}
.ql-product-tag {
/* ── Status badges ── */
.ql-status-new {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(1,105,111,0.07); color: #01696f; white-space: nowrap;
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
}
.ql-status-contacted {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(59,130,246,0.08); color: #3b82f6; white-space: nowrap;
}
.ql-status-qualified {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(16,185,129,0.08); color: #10b981; white-space: nowrap;
}
.ql-status-proposal {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(245,158,11,0.08); color: #f59e0b; white-space: nowrap;
}
.ql-status-negotiation {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(139,92,246,0.08); color: #8b5cf6; white-space: nowrap;
}
.ql-status-converted {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(34,197,94,0.08); color: #22c55e; white-space: nowrap;
}
.ql-status-lost {
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
background: rgba(107,114,128,0.08); color: #6b7280; white-space: nowrap;
}
/* ── Empty ── */