add quick leads
All checks were successful
Build and Publish / build-release (push) Successful in 4m11s
All checks were successful
Build and Publish / build-release (push) Successful in 4m11s
This commit is contained in:
@@ -188,7 +188,7 @@ const customerPolicies = computed(() => policiesData.value?.data ?? [])
|
|||||||
<span>Customer Information</span>
|
<span>Customer Information</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 text-sm text-gray-600">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,11 +17,22 @@ const emails = ref<Record<string, string>>({
|
|||||||
|
|
||||||
const roles = ['quotes', 'claims', 'renewals', 'billing', 'support']
|
const roles = ['quotes', 'claims', 'renewals', 'billing', 'support']
|
||||||
|
|
||||||
const label = computed(() => {
|
const getProviderLabel = computed(() => {
|
||||||
if (!provider.value) return ''
|
if (!provider.value) return ''
|
||||||
return provider.value.name || 'Unknown'
|
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
|
// templates and default_templates come directly from provider
|
||||||
const templates = computed(() => provider.value?.templates ?? {})
|
const templates = computed(() => provider.value?.templates ?? {})
|
||||||
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
|
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
|
||||||
@@ -81,7 +92,7 @@ async function toggleTemplate(templateId: string, active: boolean, policyType: s
|
|||||||
try {
|
try {
|
||||||
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
|
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
|
||||||
method: 'POST',
|
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' })
|
toast.add({ title: `Template ${active ? 'deactivated' : 'activated'}`, color: 'green' })
|
||||||
await refresh()
|
await refresh()
|
||||||
@@ -182,7 +193,7 @@ const clientTypeColor = (ct: string) =>
|
|||||||
from these slots.
|
from these slots.
|
||||||
</p>
|
</p>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<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" />
|
<UInput v-model="emails[role]" type="email" placeholder="name@carrier.com" class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,62 +9,91 @@ interface QuickLead {
|
|||||||
name: string
|
name: string
|
||||||
phone: string
|
phone: string
|
||||||
email: string
|
email: string
|
||||||
product: string
|
notes: string
|
||||||
source: string
|
priority: 'low' | 'medium' | 'high'
|
||||||
priority: 'normal' | 'high' | 'urgent'
|
source: 'website' | 'referral' | 'social_media' | 'cold_call' | 'email_campaign' | 'other'
|
||||||
note: string
|
status: 'new' | 'contacted' | 'qualified' | 'proposal' | 'negotiation' | 'converted' | 'lost'
|
||||||
agent: string
|
assigned_to?: string
|
||||||
createdAt: string
|
company_name?: string
|
||||||
}
|
estimated_value?: string
|
||||||
|
expected_close_date?: string
|
||||||
/* ── Storage ── */
|
inserted_at: string
|
||||||
const STORAGE_KEY = 'policy-ui.quick-leads'
|
updated_at: string
|
||||||
|
status_history?: any[]
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── State ── */
|
/* ── State ── */
|
||||||
const leads = ref<QuickLead[]>(loadLeads())
|
const page = ref(1)
|
||||||
|
const pageSize = ref(100)
|
||||||
|
|
||||||
watch(leads, (v) => {
|
/* ── List filtering ── */
|
||||||
saveLeads(v)
|
type ListFilter = 'all' | 'high' | 'medium' | 'low'
|
||||||
}, { deep: true })
|
const activeFilter = ref<ListFilter>('all')
|
||||||
|
|
||||||
function addLead(lead: Omit<QuickLead, 'id' | 'createdAt'>) {
|
// Fetch leads from API
|
||||||
const newLead: QuickLead = {
|
const { data: leadsData, pending: leadsPending, refresh: refreshLeads } = useCustomer('/leads', {
|
||||||
id: crypto.randomUUID?.() ?? String(Date.now() + Math.random()),
|
query: computed(() => {
|
||||||
createdAt: new Date().toISOString(),
|
const filters: Record<string, string> = {}
|
||||||
...lead,
|
let i = 0
|
||||||
}
|
|
||||||
leads.value = [newLead, ...leads.value]
|
if (activeFilter.value !== 'all') {
|
||||||
|
filters[`filters[${i}][field]`] = 'priority'
|
||||||
|
filters[`filters[${i}][op]`] = '=='
|
||||||
|
filters[`filters[${i}][value]`] = activeFilter.value
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeLead(id: string) {
|
return {
|
||||||
leads.value = leads.value.filter(l => l.id !== id)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentLeads(days: number): QuickLead[] {
|
// Update lead status function
|
||||||
const cutoff = Date.now() - days * 86400000
|
async function updateLeadStatus(id: string, status: QuickLead['status']) {
|
||||||
return leads.value.filter(l => new Date(l.createdAt).getTime() > cutoff)
|
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 ── */
|
/* ── Form state ── */
|
||||||
@@ -72,69 +101,109 @@ const formOpen = ref(false)
|
|||||||
const name = ref('')
|
const name = ref('')
|
||||||
const phone = ref('')
|
const phone = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const product = ref('')
|
const notes = ref('')
|
||||||
|
const priority = ref<'low' | 'medium' | 'high'>('low')
|
||||||
const source = ref('')
|
const source = ref('')
|
||||||
const priority = ref<'normal' | 'high' | 'urgent'>('normal')
|
const assigned_to = ref('')
|
||||||
const note = ref('')
|
const company_name = ref('')
|
||||||
|
const estimated_value = ref('')
|
||||||
const productOptions = [
|
const expected_close_date = ref('')
|
||||||
{ label: 'Auto', value: 'Auto' },
|
|
||||||
{ label: 'Health', value: 'Health' },
|
|
||||||
{ label: 'Life', value: 'Life' },
|
|
||||||
{ label: 'General Risk', value: 'General Risk' },
|
|
||||||
{ label: 'Custom', value: 'Custom' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const sourceOptions = [
|
const sourceOptions = [
|
||||||
{ label: 'Walk-in', value: 'walk-in' },
|
|
||||||
{ label: 'Referral', value: 'referral' },
|
|
||||||
{ label: 'Phone call', value: 'phone' },
|
|
||||||
{ label: 'Website', value: 'website' },
|
{ 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' },
|
{ label: 'Other', value: 'other' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const priorityOptions = [
|
const priorityOptions = [
|
||||||
{ label: 'Normal', value: 'normal' as const },
|
{ label: 'Low', value: 'low' as const },
|
||||||
{ label: 'High — follow up today', value: 'high' as const },
|
{ label: 'Medium', value: 'medium' as const },
|
||||||
{ label: 'Urgent — client waiting', value: 'urgent' as const },
|
{ label: 'High', value: 'high' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
function submit() {
|
const statusOptions = [
|
||||||
if (!name.value.trim() || !product.value) return
|
{ label: 'New', value: 'new' },
|
||||||
addLead({
|
{ 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(),
|
name: name.value.trim(),
|
||||||
phone: phone.value.trim(),
|
phone: phone.value.trim(),
|
||||||
email: email.value.trim(),
|
email: email.value.trim(),
|
||||||
product: product.value,
|
notes: notes.value.trim(),
|
||||||
source: source.value || 'other',
|
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
note: note.value.trim(),
|
source: source.value || 'other',
|
||||||
agent: 'Me', // placeholder
|
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' })
|
toast.add({ title: 'Lead captured', description: `${name.value} added to quick leads`, color: 'success' })
|
||||||
resetForm()
|
resetForm()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: 'Failed to create lead', description: 'Please try again', color: 'error' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
name.value = ''
|
name.value = ''
|
||||||
phone.value = ''
|
phone.value = ''
|
||||||
email.value = ''
|
email.value = ''
|
||||||
product.value = ''
|
notes.value = ''
|
||||||
|
priority.value = 'low'
|
||||||
source.value = ''
|
source.value = ''
|
||||||
priority.value = 'normal'
|
assigned_to.value = ''
|
||||||
note.value = ''
|
company_name.value = ''
|
||||||
|
estimated_value.value = ''
|
||||||
|
expected_close_date.value = ''
|
||||||
formOpen.value = false
|
formOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmRemove(id: string) {
|
async function handleStatusChange(leadId: string, newStatus: QuickLead['status']) {
|
||||||
removeLead(id)
|
try {
|
||||||
toast.add({ title: 'Lead removed', color: 'neutral' })
|
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 ── */
|
/* ── Drag and drop for status ── */
|
||||||
type ListFilter = 'all' | 'urgent' | 'high' | 'normal'
|
const draggedLead = ref<string | null>(null)
|
||||||
const activeFilter = ref<ListFilter>('all')
|
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(() => {
|
const filteredLeads = computed(() => {
|
||||||
if (activeFilter.value === 'all') return leads.value
|
if (activeFilter.value === 'all') return leads.value
|
||||||
@@ -143,16 +212,34 @@ const filteredLeads = computed(() => {
|
|||||||
|
|
||||||
const filterCounts = computed(() => ({
|
const filterCounts = computed(() => ({
|
||||||
all: leads.value.length,
|
all: leads.value.length,
|
||||||
urgent: leads.value.filter(l => l.priority === 'urgent').length,
|
|
||||||
high: leads.value.filter(l => l.priority === 'high').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 ── */
|
/* ── Helpers ── */
|
||||||
function priorityMeta(p: string) {
|
function priorityMeta(p: string) {
|
||||||
if (p === 'urgent') return { label: 'Urgent', class: 'ql-pri-urgent' }
|
if (p === 'high') return { label: 'High', class: 'ql-pri-urgent' }
|
||||||
if (p === 'high') return { label: 'High', class: 'ql-pri-high' }
|
if (p === 'medium') return { label: 'Medium', class: 'ql-pri-high' }
|
||||||
return { label: 'Normal', class: 'ql-pri-normal' }
|
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) {
|
function formatDate(iso: string) {
|
||||||
@@ -228,21 +315,33 @@ function formatDate(iso: string) {
|
|||||||
<div class="ql-section">
|
<div class="ql-section">
|
||||||
<p class="ql-section-title">Lead info</p>
|
<p class="ql-section-title">Lead info</p>
|
||||||
<div class="ql-fields">
|
<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">
|
<div class="ql-field">
|
||||||
<label class="ql-label">Source</label>
|
<label class="ql-label">Source</label>
|
||||||
<USelect v-model="source" :items="sourceOptions" placeholder="How did they find us?" size="sm" />
|
<USelect v-model="source" :items="sourceOptions" placeholder="How did they find us?" size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ql-field ql-field-full">
|
<div class="ql-field">
|
||||||
<label class="ql-label">Priority</label>
|
<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>
|
||||||
<div class="ql-field ql-field-full">
|
<div class="ql-field ql-field-full">
|
||||||
<label class="ql-label">Notes</label>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +354,7 @@ function formatDate(iso: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="button" class="ql-cancel-btn" @click="resetForm">Cancel</button>
|
<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;" />
|
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
|
||||||
Add Lead
|
Add Lead
|
||||||
</button>
|
</button>
|
||||||
@@ -272,15 +371,15 @@ function formatDate(iso: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ql-kpi">
|
<div class="ql-kpi">
|
||||||
<p class="ql-kpi-label">Last 10 days</p>
|
<p class="ql-kpi-label">Last 10 days</p>
|
||||||
<p class="ql-kpi-value">{{ recentLeads(10).length }}</p>
|
<p class="ql-kpi-value">{{ recentLeads.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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ql-kpi">
|
<div class="ql-kpi">
|
||||||
<p class="ql-kpi-label">High priority</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -290,9 +389,9 @@ function formatDate(iso: string) {
|
|||||||
<button
|
<button
|
||||||
v-for="f in ([
|
v-for="f in ([
|
||||||
{ id: 'all', label: 'All' },
|
{ id: 'all', label: 'All' },
|
||||||
{ id: 'urgent', label: 'Urgent' },
|
|
||||||
{ id: 'high', label: 'High' },
|
{ id: 'high', label: 'High' },
|
||||||
{ id: 'normal', label: 'Normal' },
|
{ id: 'medium', label: 'Medium' },
|
||||||
|
{ id: 'low', label: 'Low' },
|
||||||
] as { id: ListFilter; label: string }[])"
|
] as { id: ListFilter; label: string }[])"
|
||||||
:key="f.id"
|
:key="f.id"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -308,7 +407,12 @@ function formatDate(iso: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Leads list ═══ -->
|
<!-- ═══ 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;" />
|
<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>
|
<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">
|
<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">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ lead.name }}</p>
|
<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="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>
|
||||||
<div class="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--text-muted)]">
|
<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.phone">{{ lead.phone }}</span>
|
||||||
<span v-if="lead.email">{{ lead.email }}</span>
|
<span v-if="lead.email">{{ lead.email }}</span>
|
||||||
<span>{{ formatDate(lead.createdAt) }}</span>
|
<span>{{ formatDate(lead.inserted_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ql-lead-actions">
|
<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">
|
<NuxtLink :to="`/quotes/new`" title="Start quote">
|
||||||
<button type="button" class="ql-action-btn ql-action-quote">
|
<button type="button" class="ql-action-btn ql-action-quote">
|
||||||
<UIcon name="i-heroicons-calculator" style="width: 14px; height: 14px;" />
|
<UIcon name="i-heroicons-calculator" style="width: 14px; height: 14px;" />
|
||||||
</button>
|
</button>
|
||||||
</NuxtLink>
|
</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>
|
</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;" />
|
<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>
|
||||||
<div class="ql-lead-meta">
|
<div class="ql-lead-meta">
|
||||||
<span v-if="lead.source" class="ql-meta-tag">{{ lead.source }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
@@ -546,9 +665,35 @@ function formatDate(iso: string) {
|
|||||||
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
|
font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px;
|
||||||
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
|
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;
|
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 ── */
|
/* ── Empty ── */
|
||||||
|
|||||||
@@ -18,5 +18,39 @@
|
|||||||
},
|
},
|
||||||
"plugin": [
|
"plugin": [
|
||||||
"opencode-browser"
|
"opencode-browser"
|
||||||
]
|
],
|
||||||
|
"agent": {
|
||||||
|
"bug-finder": {
|
||||||
|
"description": "Systematically tests all UI pages using webmcp and documents bugs in BUGS.md",
|
||||||
|
"model": "z-ai/glm4.7",
|
||||||
|
"prompt": "You are a UI bug finder agent. Your task is to systematically test all pages in the application using browsermcp tools and document any bugs found in BUGS.md.\n\nTesting Methodology:\n1. Start at http://localhost:3000/\n2. Navigate through all pages systematically\n3. For each page, test: page loads without errors, data displays correctly, forms validate properly, interactive elements work, responsive layout, page refresh behavior, navigation links work, no console errors\n4. Document bugs with priority classification (Critical, High, Medium, Low)\n5. Use the exact format from existing BUGS.md for consistency\n\nPages to test (in order):\n- /, /customers, /customers/new, /customers/[id]\n- /policies, /policies/new, /policies/[id], /policies/book, /policies/groups\n- /quotes/new (test all tabs: car, life, fire_structure, fire_contents)\n- /providers, /providers/new, /providers/[provider_id]\n- /settings and all subpages\n- /workstation pages (customer-service, renewals, facturacion, collectivos, claims, collections)\n- /calendar, /collections, /analysis, /account\n- /ai-tools pages (sales-factory, policy-comparator, email-writer, case-assistant)\n- /back-office/workload, /back-office/workload/[id], /back-office/workload/kanban\n- /sales/leads, /sales/quick-lead\n\nBug Documentation Format:\n### [Priority] Bug Title\n- **Issue**: Clear description\n- **Location**: Page/route\n- **Impact**: User experience severity \n- **Status**: Pending\n- **Test Results**: Specific observations\n\nTrack visited URLs and update BUGS.md with findings.",
|
||||||
|
"tools": {
|
||||||
|
"browsermcp_browser_navigate": true,
|
||||||
|
"browsermcp_browser_snapshot": true,
|
||||||
|
"browsermcp_browser_click": true,
|
||||||
|
"browsermcp_browser_type": true,
|
||||||
|
"browsermcp_browser_select_option": true,
|
||||||
|
"browsermcp_browser_screenshot": true,
|
||||||
|
"browsermcp_browser_get_console_logs": true,
|
||||||
|
"read": true,
|
||||||
|
"write": true,
|
||||||
|
"edit": true,
|
||||||
|
"glob": true,
|
||||||
|
"grep": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bug-fixer": {
|
||||||
|
"description": "Reads BUGS.md and systematically fixes all documented bugs",
|
||||||
|
"model": "z-ai/glm4.7",
|
||||||
|
"prompt": "You are a bug fixer agent. Your task is to read BUGS.md and systematically fix all documented bugs.\n\nFix Process:\n1. Read BUGS.md to identify all pending bugs\n2. Categorize bugs by type (routing, validation, layout, data, UI)\n3. Fix bugs in severity order: Critical → High → Medium → Low\n4. For each bug:\n - Locate affected files using glob and grep\n - Understand root cause by reading relevant code\n - Implement fix following existing patterns and conventions\n - Update bug status in BUGS.md with ✅ FIXED\n - Add notes about what was fixed\n\nCode Quality Guidelines:\n- Follow existing code conventions and patterns\n- Use existing libraries and utilities\n- Maintain consistency with similar fixes\n- No comments unless requested\n- Test fixes if possible\n\nAfter fixing all bugs, provide summary of fixes made.",
|
||||||
|
"tools": {
|
||||||
|
"read": true,
|
||||||
|
"write": true,
|
||||||
|
"edit": true,
|
||||||
|
"glob": true,
|
||||||
|
"grep": true,
|
||||||
|
"bash": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user