big refactor
This commit is contained in:
@@ -1,479 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { emptyAutoQuoteDraft } from '~/composables/useAutoQuoteDraft'
|
||||
import type { AutoQuoteIntakePayload, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
import { usePolicyApi } from '~/composables/usePolicyApi'
|
||||
|
||||
/** Client-only: many Nuxt UI fields on this screen can stall hydration / main thread if SSR + client fight */
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · Auto')
|
||||
|
||||
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
solicit: 'Quotes to solicit',
|
||||
acceptance: 'Acceptance'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyAutoQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
|
||||
// Use policy API composable
|
||||
const { submitPolicyQuote } = usePolicyApi()
|
||||
|
||||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One package — we'll email carriers' quoting inboxes on file.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Personal auto.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Business or group.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
title: 'Fleet',
|
||||
hint: 'Fleet program.',
|
||||
icon: 'i-heroicons-truck'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!isInsuredValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete insured information',
|
||||
description: `Missing: ${validationErrors.value.insured.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!isBuyerValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete buyer information',
|
||||
description: `Missing: ${validationErrors.value.buyer.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one insurance company and one coverage package.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): AutoQuoteIntakePayload {
|
||||
return {
|
||||
policy_type: 'car',
|
||||
insured: insured.value,
|
||||
buyer: buyer.value,
|
||||
policy_details: { ...draft.vehicle },
|
||||
selected_providers: draft.solicit.carrierIds.map(id => ({
|
||||
provider_id: id,
|
||||
email: getProviderEmail(id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderEmail(providerId: string): string {
|
||||
// This would come from the providers API
|
||||
// For now, return a placeholder
|
||||
return `quotes@${providerId}.com`
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'auto', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Submit to policy API
|
||||
const data = await submitPolicyQuote(payload)
|
||||
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// Navigate to policy detail page
|
||||
await navigateTo(`/policies/${data.application_id}`)
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyAutoQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One package — we’ll email carriers’ quoting inboxes on file.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Personal auto.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Business or group.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
title: 'Fleet',
|
||||
hint: 'Fleet program.',
|
||||
icon: 'i-heroicons-truck'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromCustomer() {
|
||||
const c = draft.client
|
||||
if (!c.fullName.trim() || !c.email.trim()) {
|
||||
toast.add({
|
||||
title: 'Add legal name and email',
|
||||
description: 'We need them for carrier notifications.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!canProceedFromCustomer()) return false
|
||||
if (
|
||||
(draft.segment === 'corporate' || draft.segment === 'fleet') &&
|
||||
!draft.client.organizationName?.trim()
|
||||
) {
|
||||
toast.add({
|
||||
title: 'Add organization',
|
||||
description: 'Required for corporate and fleet policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one insurance company and one coverage package.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): AutoQuoteIntakePayload {
|
||||
return {
|
||||
quoteMode: draft.quoteMode!,
|
||||
segment: draft.segment!,
|
||||
client: { ...draft.client },
|
||||
vehicle: { ...draft.vehicle },
|
||||
solicit: {
|
||||
carrierIds: [...draft.solicit.carrierIds],
|
||||
planIds: [...draft.solicit.planIds]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'auto', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Auto quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Set up the risk, choose who to solicit, then accept — three steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<QuotesAutoSetupStep
|
||||
v-if="step === 'setup'"
|
||||
:draft="draft"
|
||||
:mode-cards="modeCards"
|
||||
:segment-cards="segmentCards"
|
||||
/>
|
||||
|
||||
<QuotesAutoSolicitQuotesStep
|
||||
v-else-if="step === 'solicit' && draft.quoteMode"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
/>
|
||||
|
||||
<QuotesAutoAcceptanceStep
|
||||
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
:segment="draft.segment"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton
|
||||
v-if="step !== 'setup'"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
@click="goPrev"
|
||||
>
|
||||
Back
|
||||
</UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext">
|
||||
Continue
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="intakeBusy"
|
||||
:disabled="intakeBusy"
|
||||
@click="finalize"
|
||||
>
|
||||
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Quotes · Custom')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const form = reactive({
|
||||
clientName: '',
|
||||
clientEmail: '',
|
||||
clientPhone: '',
|
||||
lineOfBusiness: '',
|
||||
policyType: '',
|
||||
carrier: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
sumInsured: null as number | null,
|
||||
premium: null as number | null,
|
||||
commissionPct: null as number | null,
|
||||
deductible: null as number | null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const commissionAmount = computed(() => {
|
||||
if (form.premium && form.commissionPct) return ((form.premium * form.commissionPct) / 100).toFixed(2)
|
||||
return '0.00'
|
||||
})
|
||||
|
||||
const linesOfBusiness = ['Auto', 'Health', 'Life', 'Property', 'Liability', 'Marine', 'Surety', 'Other']
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'
|
||||
const labelCls = 'block text-sm font-semibold text-[var(--text-muted)] mb-1.5'
|
||||
|
||||
async function save() {
|
||||
if (!form.clientName.trim() || !form.lineOfBusiness) {
|
||||
toast.add({ title: 'Missing fields', description: 'Client name and line of business are required.', color: 'warning' })
|
||||
return
|
||||
}
|
||||
toast.add({ title: 'Quote saved', description: 'Your custom quote has been recorded.', color: 'success' })
|
||||
await navigateTo('/quotes')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Custom quote entry</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Manually enter a quote for any line of business — quick single-page form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Client</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Client information</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Client name *</label>
|
||||
<input v-model="form.clientName" :class="inputCls" placeholder="Full name" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Email</label>
|
||||
<input v-model="form.clientEmail" type="email" :class="inputCls" placeholder="client@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Phone</label>
|
||||
<input v-model="form.clientPhone" type="tel" :class="inputCls" placeholder="+1 (555) 000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Policy</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Policy details</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label :class="labelCls">Line of business *</label>
|
||||
<select v-model="form.lineOfBusiness" :class="inputCls">
|
||||
<option value="" disabled>Select line</option>
|
||||
<option v-for="l in linesOfBusiness" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Policy type / description</label>
|
||||
<input v-model="form.policyType" :class="inputCls" placeholder="e.g. Comprehensive auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Carrier</label>
|
||||
<input v-model="form.carrier" :class="inputCls" placeholder="Carrier name" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Start date</label>
|
||||
<input v-model="form.startDate" type="date" :class="inputCls" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">End date</label>
|
||||
<input v-model="form.endDate" type="date" :class="inputCls" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label :class="labelCls">Sum insured / limit</label>
|
||||
<input v-model.number="form.sumInsured" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Deductible</label>
|
||||
<input v-model.number="form.deductible" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Premium</label>
|
||||
<input v-model.number="form.premium" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Commission %</label>
|
||||
<input v-model.number="form.commissionPct" type="number" :class="inputCls" placeholder="0" min="0" max="100" step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Commission amount</label>
|
||||
<div :class="[inputCls, 'flex items-center bg-[var(--brand-soft)]/30 font-medium']">
|
||||
{{ commissionAmount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Notes</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Special conditions</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label :class="labelCls">Special conditions / notes</label>
|
||||
<textarea v-model="form.notes" :class="inputCls" rows="4" placeholder="Any additional terms, conditions, or notes..." />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink to="/quotes">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton color="primary" @click="save">Save quote</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,321 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · General Risk')
|
||||
|
||||
const STEP_ORDER = ['setup', 'details', 'review'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
details: 'Risk details',
|
||||
review: 'Review & submit'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
const maxStepIndex = ref(0)
|
||||
const busy = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const form = reactive({
|
||||
quoteType: '' as '' | 'single' | 'comparative',
|
||||
riskCategory: '' as '' | 'property' | 'liability' | 'fire' | 'all-risk' | 'commercial' | 'other',
|
||||
clientName: '',
|
||||
clientEmail: '',
|
||||
clientPhone: '',
|
||||
clientOrganization: '',
|
||||
propertyAddress: '',
|
||||
propertyDescription: '',
|
||||
sumInsured: '',
|
||||
coverageStart: '',
|
||||
coverageEnd: '',
|
||||
specialConditions: '',
|
||||
deductible: ''
|
||||
})
|
||||
|
||||
const inputClass = 'w-full rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'
|
||||
const labelClass = 'block text-sm font-semibold text-[var(--text-muted)] mb-1.5'
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!form.quoteType) {
|
||||
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!form.riskCategory) {
|
||||
toast.add({ title: 'Choose risk category', description: 'Select the type of risk.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!form.clientName.trim() || !form.clientEmail.trim()) {
|
||||
toast.add({ title: 'Client info required', description: 'Name and email are needed.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromDetails() {
|
||||
if (!form.sumInsured) {
|
||||
toast.add({ title: 'Sum insured required', description: 'Enter the total sum insured.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!form.coverageStart || !form.coverageEnd) {
|
||||
toast.add({ title: 'Coverage period required', description: 'Enter start and end dates.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onStepPillClick(idx: number, target: StepId) {
|
||||
if (idx > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'details' && !canProceedFromDetails()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
toast.add({
|
||||
title: 'General risk quote submitted',
|
||||
description: 'Your quote request has been recorded and is ready for placement.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo('/quotes')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const riskCategoryLabel: Record<string, string> = {
|
||||
property: 'Property', liability: 'Liability', fire: 'Fire',
|
||||
'all-risk': 'All-Risk', commercial: 'Commercial', other: 'Other'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">General risk quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Property, liability, fire, all-risk, and commercial lines — set up, detail, review.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 1: Quote setup -->
|
||||
<div v-if="step === 'setup'" class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Quote type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single placement or comparative — comparative sends the same risk to multiple carriers.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="opt in [
|
||||
{ id: 'single', label: 'Single quote', hint: 'One risk, one placement.', icon: 'i-heroicons-document-text' },
|
||||
{ id: 'comparative', label: 'Comparative quote', hint: 'Same risk sent to multiple carriers.', icon: 'i-heroicons-document-duplicate' }
|
||||
]"
|
||||
:key="opt.id" type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="form.quoteType === opt.id ? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30' : 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'"
|
||||
@click="form.quoteType = opt.id as 'single' | 'comparative'"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="opt.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ opt.label }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ opt.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Risk category</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">What type of risk are you quoting?</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="cat in [
|
||||
{ id: 'property', icon: 'i-heroicons-building-office-2' },
|
||||
{ id: 'liability', icon: 'i-heroicons-shield-exclamation' },
|
||||
{ id: 'fire', icon: 'i-heroicons-fire' },
|
||||
{ id: 'all-risk', icon: 'i-heroicons-shield-check' },
|
||||
{ id: 'commercial', icon: 'i-heroicons-building-storefront' },
|
||||
{ id: 'other', icon: 'i-heroicons-ellipsis-horizontal-circle' }
|
||||
]"
|
||||
:key="cat.id" type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="form.riskCategory === cat.id ? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30' : 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'"
|
||||
@click="form.riskCategory = cat.id as typeof form.riskCategory"
|
||||
>
|
||||
<UIcon :name="cat.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 text-sm font-semibold text-[var(--text-primary)]">{{ riskCategoryLabel[cat.id] }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Client information</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label :class="labelClass">Full name</label>
|
||||
<input v-model="form.clientName" type="text" :class="inputClass" placeholder="Jane Doe" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Email</label>
|
||||
<input v-model="form.clientEmail" type="email" :class="inputClass" placeholder="jane@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Phone</label>
|
||||
<input v-model="form.clientPhone" type="tel" :class="inputClass" placeholder="+27 82 000 0000" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Organization</label>
|
||||
<input v-model="form.clientOrganization" type="text" :class="inputClass" placeholder="Acme Corp" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Step 2: Risk details -->
|
||||
<div v-else-if="step === 'details'" class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label :class="labelClass">Property / risk address</label>
|
||||
<input v-model="form.propertyAddress" type="text" :class="inputClass" placeholder="123 Main St, Johannesburg" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label :class="labelClass">Risk description</label>
|
||||
<textarea v-model="form.propertyDescription" :class="inputClass" rows="3" placeholder="Describe the property or risk in detail" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Sum insured (ZAR)</label>
|
||||
<input v-model="form.sumInsured" type="number" :class="inputClass" placeholder="1 000 000" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Deductible (ZAR)</label>
|
||||
<input v-model="form.deductible" type="number" :class="inputClass" placeholder="10 000" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Coverage start</label>
|
||||
<input v-model="form.coverageStart" type="date" :class="inputClass" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">Coverage end</label>
|
||||
<input v-model="form.coverageEnd" type="date" :class="inputClass" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label :class="labelClass">Special conditions</label>
|
||||
<textarea v-model="form.specialConditions" :class="inputClass" rows="3" placeholder="Any special conditions, endorsements, or exclusions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Review & submit -->
|
||||
<div v-else-if="step === 'review'" class="space-y-5">
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<p class="text-sm font-semibold text-[var(--text-muted)] mb-3">Quote setup</p>
|
||||
<dl class="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-[var(--text-muted)]">Quote type</dt><dd class="font-medium text-[var(--text-primary)] capitalize">{{ form.quoteType || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Risk category</dt><dd class="font-medium text-[var(--text-primary)]">{{ riskCategoryLabel[form.riskCategory] || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Client</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientName || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Email</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientEmail || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Phone</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientPhone || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Organization</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientOrganization || '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<p class="text-sm font-semibold text-[var(--text-muted)] mb-3">Risk details</p>
|
||||
<dl class="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
|
||||
<div class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Address</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.propertyAddress || '—' }}</dd></div>
|
||||
<div class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Description</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.propertyDescription || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Sum insured</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.sumInsured ? `R ${Number(form.sumInsured).toLocaleString()}` : '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Deductible</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.deductible ? `R ${Number(form.deductible).toLocaleString()}` : '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Coverage start</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.coverageStart || '—' }}</dd></div>
|
||||
<div><dt class="text-[var(--text-muted)]">Coverage end</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.coverageEnd || '—' }}</dd></div>
|
||||
<div v-if="form.specialConditions" class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Special conditions</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.specialConditions }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev">Back</UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'review'" type="button" color="primary" @click="goNext">Continue</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="busy"
|
||||
:disabled="busy"
|
||||
@click="finalize"
|
||||
>
|
||||
Finalize quote
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { emptyHealthQuoteDraft } from '~/composables/useHealthQuoteDraft'
|
||||
import type { HealthQuoteIntakePayload, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · Health')
|
||||
|
||||
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
solicit: 'Carriers to quote',
|
||||
acceptance: 'Acceptance'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyHealthQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
const modeCards: { id: HealthQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One benefit package per carrier request.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same census and coverage intent; fill the comparative sheet when rates arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: HealthQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Retail medical.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Employer-sponsored.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
title: 'Group policy',
|
||||
hint: 'Trust or association block.',
|
||||
icon: 'i-heroicons-user-group'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromCustomer() {
|
||||
const c = draft.client
|
||||
if (!c.fullName.trim() || !c.email.trim()) {
|
||||
toast.add({
|
||||
title: 'Add legal name and email',
|
||||
description: 'We need them for notifications and the quote file.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or group.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!canProceedFromCustomer()) return false
|
||||
if (
|
||||
(draft.segment === 'corporate' || draft.segment === 'group') &&
|
||||
!draft.client.organizationName?.trim()
|
||||
) {
|
||||
toast.add({
|
||||
title: 'Add organization',
|
||||
description: 'Required for corporate and group policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!draft.health.dateOfBirth) {
|
||||
toast.add({
|
||||
title: 'Add date of birth',
|
||||
description: 'Age is required for health underwriting and rate-band lookup.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!draft.health.coverageArea || !draft.health.networkTier || !draft.health.deductible) {
|
||||
toast.add({
|
||||
title: 'Complete coverage intent',
|
||||
description: 'Select coverage area, network tier, and deductible.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!draft.forms.medicalQuestionnaire || !draft.forms.beneficiaryDesignation) {
|
||||
toast.add({
|
||||
title: 'Confirm forms',
|
||||
description: 'Check off medical questionnaire and beneficiary designation.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (draft.segment === 'group' && !draft.forms.groupCensus) {
|
||||
toast.add({
|
||||
title: 'Group census required',
|
||||
description: 'Confirm the employee roster / census for group policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one carrier and one plan shell.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): HealthQuoteIntakePayload {
|
||||
return {
|
||||
quoteMode: draft.quoteMode!,
|
||||
segment: draft.segment!,
|
||||
client: { ...draft.client },
|
||||
health: { ...draft.health },
|
||||
solicit: {
|
||||
carrierIds: [...draft.solicit.carrierIds],
|
||||
planIds: [...draft.solicit.planIds]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'health', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Health quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Set up the member, complete forms, pick carriers, then accept — three steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<QuotesHealthSetupStep
|
||||
v-if="step === 'setup'"
|
||||
:draft="draft"
|
||||
:mode-cards="modeCards"
|
||||
:segment-cards="segmentCards"
|
||||
/>
|
||||
|
||||
<QuotesHealthSolicitQuotesStep
|
||||
v-else-if="step === 'solicit' && draft.quoteMode"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
/>
|
||||
|
||||
<QuotesHealthAcceptanceStep
|
||||
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
:segment="draft.segment"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev"> Back </UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext"> Continue </UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="intakeBusy"
|
||||
:disabled="intakeBusy"
|
||||
@click="finalize"
|
||||
>
|
||||
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,321 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { emptyLifeQuoteDraft } from '~/composables/useLifeQuoteDraft'
|
||||
import type { LifeQuoteIntakePayload, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
import { usePolicyApi } from '~/composables/usePolicyApi'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · Life')
|
||||
|
||||
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
solicit: 'Carriers to quote',
|
||||
acceptance: 'Acceptance'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyLifeQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
|
||||
// Use policy API composable
|
||||
const { submitPolicyQuote } = usePolicyApi()
|
||||
|
||||
const modeCards: { id: LifeQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One benefit package per carrier request.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same insured and coverage intent; fill the comparative sheet when rates arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: LifeQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Retail life insurance.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate_keyman',
|
||||
title: 'Corporate / Key person',
|
||||
hint: 'Key-man or employer-sponsored.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
title: 'Group policy',
|
||||
hint: 'Trust or association block.',
|
||||
icon: 'i-heroicons-user-group'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate / key person, or group.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!isInsuredValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete insured information',
|
||||
description: `Missing: ${validationErrors.value.insured.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!isBuyerValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete buyer information',
|
||||
description: `Missing: ${validationErrors.value.buyer.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!draft.life.coverage_type || !draft.life.coverage_amount || !draft.life.coverage_years) {
|
||||
toast.add({
|
||||
title: 'Complete coverage details',
|
||||
description: 'Coverage type, amount, and years are required.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!draft.forms.medicalQuestionnaire || !draft.forms.beneficiaryDesignation) {
|
||||
toast.add({
|
||||
title: 'Confirm forms',
|
||||
description: 'Check off medical questionnaire and beneficiary designation.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (draft.segment === 'group' && !draft.forms.groupCensus) {
|
||||
toast.add({
|
||||
title: 'Group census required',
|
||||
description: 'Confirm the employee roster / census for group policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one carrier and one plan shell.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): LifeQuoteIntakePayload {
|
||||
return {
|
||||
policy_type: 'life',
|
||||
insured: insured.value,
|
||||
buyer: buyer.value,
|
||||
policy_details: { ...draft.life },
|
||||
selected_providers: draft.solicit.carrierIds.map(id => ({
|
||||
provider_id: id,
|
||||
email: getProviderEmail(id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderEmail(providerId: string): string {
|
||||
// This would come from the providers API
|
||||
// For now, return a placeholder
|
||||
return `quotes@${providerId}.com`
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings -> Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'life', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Submit to policy API
|
||||
const data = await submitPolicyQuote(payload)
|
||||
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// Navigate to policy detail page
|
||||
await navigateTo(`/policies/${data.application_id}`)
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Life quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Set up the insured, complete screening, pick carriers, then accept — three steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<QuotesLifeSetupStep
|
||||
v-if="step === 'setup'"
|
||||
:draft="draft"
|
||||
:mode-cards="modeCards"
|
||||
:segment-cards="segmentCards"
|
||||
/>
|
||||
|
||||
<QuotesLifeSolicitQuotesStep
|
||||
v-else-if="step === 'solicit' && draft.quoteMode"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
/>
|
||||
|
||||
<QuotesLifeAcceptanceStep
|
||||
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
:segment="draft.segment"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev"> Back </UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext"> Continue </UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="intakeBusy"
|
||||
:disabled="intakeBusy"
|
||||
@click="finalize"
|
||||
>
|
||||
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,662 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '~/composables/useSalesPipeline'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Mission Control \u00b7 Quotes')
|
||||
|
||||
/* ── Mock agents ── */
|
||||
interface AgentStats {
|
||||
name: string
|
||||
activeDeals: number
|
||||
quotesSent: number
|
||||
conversionRate: number
|
||||
avgResponseTime: string
|
||||
pipelineValue: number
|
||||
}
|
||||
|
||||
const agents: AgentStats[] = [
|
||||
{ name: 'Ana Ram\u00edrez', activeDeals: 8, quotesSent: 14, conversionRate: 71, avgResponseTime: '1.2d', pipelineValue: 18_400 },
|
||||
{ name: 'Marco Villanueva', activeDeals: 4, quotesSent: 6, conversionRate: 83, avgResponseTime: '0.8d', pipelineValue: 22_100 },
|
||||
{ name: 'Luc\u00eda Fern\u00e1ndez', activeDeals: 6, quotesSent: 4, conversionRate: 42, avgResponseTime: '2.4d', pipelineValue: 4_700 },
|
||||
]
|
||||
|
||||
/* ── Mock deals spread across pipeline ── */
|
||||
interface MockDeal {
|
||||
id: string
|
||||
customerName: string
|
||||
productLine: string
|
||||
currentStage: PipelineStage
|
||||
agent: string
|
||||
daysAtStage: number
|
||||
premium: number
|
||||
formCompletion: number
|
||||
createdDaysAgo: number
|
||||
}
|
||||
|
||||
const mockDeals: MockDeal[] = [
|
||||
{ id: 'd01', customerName: 'Mar\u00eda Elena P\u00e9rez', productLine: 'Auto', currentStage: 'customer', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1200, formCompletion: 45, createdDaysAgo: 1 },
|
||||
{ id: 'd02', customerName: 'Carlos Mendoza', productLine: 'Life', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 3, premium: 3200, formCompletion: 20, createdDaysAgo: 3 },
|
||||
{ id: 'd03', customerName: 'Laura Castillo', productLine: 'Health', currentStage: 'get_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 2800, formCompletion: 60, createdDaysAgo: 5 },
|
||||
{ id: 'd04', customerName: 'Sof\u00eda Rojas Delgado', productLine: 'Auto', currentStage: 'get_quotes', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 4, premium: 1500, formCompletion: 50, createdDaysAgo: 7 },
|
||||
{ id: 'd05', customerName: 'Andr\u00e9s Vargas', productLine: 'General Risk', currentStage: 'get_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 8500, formCompletion: 75, createdDaysAgo: 4 },
|
||||
{ id: 'd06', customerName: 'Patricia Herrera', productLine: 'Auto', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 6, premium: 1100, formCompletion: 100, createdDaysAgo: 10 },
|
||||
{ id: 'd07', customerName: 'Fernando L\u00f3pez', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 8, premium: 4200, formCompletion: 100, createdDaysAgo: 12 },
|
||||
{ id: 'd08', customerName: 'Roberto Jim\u00e9nez Mora', productLine: 'Health', currentStage: 'waiting_carriers', agent: 'Marco Villanueva', daysAtStage: 3, premium: 3100, formCompletion: 100, createdDaysAgo: 8 },
|
||||
{ id: 'd09', customerName: 'Gabriela Torres', productLine: 'Auto', currentStage: 'present_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 1400, formCompletion: 100, createdDaysAgo: 14 },
|
||||
{ id: 'd10', customerName: 'Diego Salazar', productLine: 'Life', currentStage: 'present_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 5600, formCompletion: 100, createdDaysAgo: 9 },
|
||||
{ id: 'd11', customerName: 'Isabel Moreno', productLine: 'General Risk', currentStage: 'waiting_client', agent: 'Ana Ram\u00edrez', daysAtStage: 7, premium: 2200, formCompletion: 100, createdDaysAgo: 16 },
|
||||
{ id: 'd12', customerName: 'Alejandro Rios', productLine: 'Auto', currentStage: 'waiting_client', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 12, premium: 900, formCompletion: 100, createdDaysAgo: 20 },
|
||||
{ id: 'd13', customerName: 'Valentina Cruz', productLine: 'Health', currentStage: 'solicitud', agent: 'Ana Ram\u00edrez', daysAtStage: 3, premium: 2700, formCompletion: 68, createdDaysAgo: 18 },
|
||||
{ id: 'd14', customerName: 'Jorge Navarro', productLine: 'Life', currentStage: 'solicitud', agent: 'Marco Villanueva', daysAtStage: 2, premium: 6800, formCompletion: 85, createdDaysAgo: 15 },
|
||||
{ id: 'd15', customerName: 'Camila Fuentes', productLine: 'Auto', currentStage: 'emission', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1600, formCompletion: 92, createdDaysAgo: 22 },
|
||||
{ id: 'd16', customerName: 'Ra\u00fal Espinoza', productLine: 'General Risk', currentStage: 'emission', agent: 'Marco Villanueva', daysAtStage: 2, premium: 7200, formCompletion: 100, createdDaysAgo: 25 },
|
||||
{ id: 'd17', customerName: 'M\u00f3nica Delgado', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 11, premium: 3800, formCompletion: 100, createdDaysAgo: 15 },
|
||||
{ id: 'd18', customerName: 'Enrique Paredes', productLine: 'Health', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 6, premium: 2100, formCompletion: 30, createdDaysAgo: 6 },
|
||||
]
|
||||
|
||||
/* ── KPI computations ── */
|
||||
const totalActiveDeals = computed(() => mockDeals.length)
|
||||
|
||||
const totalPipelinePremium = computed(() =>
|
||||
mockDeals.reduce((sum, d) => sum + d.premium, 0),
|
||||
)
|
||||
|
||||
const stageCounts = computed(() => {
|
||||
const counts: Record<PipelineStage, number> = {} as any
|
||||
for (const s of PIPELINE_STAGES) counts[s.id] = 0
|
||||
for (const d of mockDeals) counts[d.currentStage]++
|
||||
return counts
|
||||
})
|
||||
|
||||
const bottleneckStage = computed(() => {
|
||||
let max = 0
|
||||
let stage = PIPELINE_STAGES[0]
|
||||
for (const s of PIPELINE_STAGES) {
|
||||
if (stageCounts.value[s.id] > max) {
|
||||
max = stageCounts.value[s.id]
|
||||
stage = s
|
||||
}
|
||||
}
|
||||
return stage.label
|
||||
})
|
||||
|
||||
const maxStageCount = computed(() =>
|
||||
Math.max(...PIPELINE_STAGES.map(s => stageCounts.value[s.id]), 1),
|
||||
)
|
||||
|
||||
/* ── Funnel colors ── */
|
||||
const stageColors: Record<PipelineStage, string> = {
|
||||
customer: '#01696f',
|
||||
get_quotes: '#0d8a8f',
|
||||
waiting_carriers: '#f59e0b',
|
||||
present_quotes: '#3b82f6',
|
||||
waiting_client: '#f59e0b',
|
||||
solicitud: '#8b5cf6',
|
||||
emission: '#10b981',
|
||||
}
|
||||
|
||||
/* ── Bottleneck deals (stuck > 5 days) ── */
|
||||
const stuckDeals = computed(() =>
|
||||
mockDeals
|
||||
.filter(d => d.daysAtStage > 5)
|
||||
.sort((a, b) => b.daysAtStage - a.daysAtStage),
|
||||
)
|
||||
|
||||
function severityColor(days: number) {
|
||||
if (days > 10) return '#ef4444'
|
||||
if (days > 5) return '#f59e0b'
|
||||
return '#6b7280'
|
||||
}
|
||||
|
||||
function severityBg(days: number) {
|
||||
if (days > 10) return 'rgba(239,68,68,0.08)'
|
||||
if (days > 5) return 'rgba(245,158,11,0.08)'
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
/* ── Stage breakdown tab ── */
|
||||
const activeStageTab = ref<PipelineStage>('customer')
|
||||
|
||||
const stageDeals = computed(() =>
|
||||
mockDeals.filter(d => d.currentStage === activeStageTab.value),
|
||||
)
|
||||
|
||||
function stageLabelFor(id: PipelineStage) {
|
||||
return PIPELINE_STAGES.find(s => s.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
function formatCurrency(v: number) {
|
||||
return '$' + v.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const nudgeConfirmDeal = ref<MockDeal | null>(null)
|
||||
|
||||
function onNudge(deal: MockDeal) {
|
||||
nudgeConfirmDeal.value = deal
|
||||
}
|
||||
|
||||
function confirmNudge() {
|
||||
if (nudgeConfirmDeal.value) {
|
||||
toast.add({ title: `Nudge sent to ${nudgeConfirmDeal.value.customerName}`, color: 'success' })
|
||||
}
|
||||
nudgeConfirmDeal.value = null
|
||||
}
|
||||
|
||||
function cancelNudge() {
|
||||
nudgeConfirmDeal.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mc-page">
|
||||
<!-- Header -->
|
||||
<div class="mc-header">
|
||||
<h1 class="mc-title">Mission Control</h1>
|
||||
<p class="mc-subtitle">Pipeline analytics & agent performance</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Strip -->
|
||||
<div class="mc-kpi-strip">
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Total Active Deals</span>
|
||||
<span class="mc-kpi-value">{{ totalActiveDeals }}</span>
|
||||
</div>
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Quotes This Month</span>
|
||||
<span class="mc-kpi-value">24</span>
|
||||
</div>
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Avg Days in Pipeline</span>
|
||||
<span class="mc-kpi-value">8.3</span>
|
||||
</div>
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Conversion Rate</span>
|
||||
<span class="mc-kpi-value">62%</span>
|
||||
</div>
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Pipeline Premium</span>
|
||||
<span class="mc-kpi-value">{{ formatCurrency(totalPipelinePremium) }}</span>
|
||||
</div>
|
||||
<div class="mc-card mc-kpi-card">
|
||||
<span class="mc-kpi-label">Bottleneck Stage</span>
|
||||
<span class="mc-kpi-value mc-kpi-value--bottleneck">{{ bottleneckStage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Funnel -->
|
||||
<div class="mc-card mc-section">
|
||||
<h2 class="mc-section-title">Pipeline Funnel</h2>
|
||||
<div class="mc-funnel">
|
||||
<div
|
||||
v-for="stage in PIPELINE_STAGES"
|
||||
:key="stage.id"
|
||||
class="mc-funnel-row"
|
||||
>
|
||||
<span class="mc-funnel-label">{{ stage.label }}</span>
|
||||
<div class="mc-funnel-bar-track">
|
||||
<div
|
||||
class="mc-funnel-bar"
|
||||
:style="{
|
||||
width: Math.max((stageCounts[stage.id] / maxStageCount) * 100, 4) + '%',
|
||||
background: stageColors[stage.id],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="mc-funnel-count">{{ stageCounts[stage.id] }}</span>
|
||||
<span class="mc-funnel-pct">
|
||||
{{ totalActiveDeals ? Math.round((stageCounts[stage.id] / totalActiveDeals) * 100) : 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Performance -->
|
||||
<div class="mc-card mc-section">
|
||||
<h2 class="mc-section-title">Agent Performance</h2>
|
||||
<div class="mc-table-wrap">
|
||||
<table class="mc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Active Deals</th>
|
||||
<th>Quotes Sent</th>
|
||||
<th>Conversion</th>
|
||||
<th>Avg Response</th>
|
||||
<th>Pipeline Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in agents" :key="a.name">
|
||||
<td class="mc-agent-name">{{ a.name }}</td>
|
||||
<td>{{ a.activeDeals }}</td>
|
||||
<td>{{ a.quotesSent }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="mc-badge"
|
||||
:style="{ background: a.conversionRate >= 70 ? 'rgba(16,185,129,0.1)' : a.conversionRate >= 50 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)', color: a.conversionRate >= 70 ? '#059669' : a.conversionRate >= 50 ? '#d97706' : '#dc2626' }"
|
||||
>
|
||||
{{ a.conversionRate }}%
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ a.avgResponseTime }}</td>
|
||||
<td>{{ formatCurrency(a.pipelineValue) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottleneck Analysis -->
|
||||
<div class="mc-card mc-section">
|
||||
<h2 class="mc-section-title">Bottleneck Analysis</h2>
|
||||
<p class="mc-section-desc">Deals stuck at a stage for more than 5 days</p>
|
||||
<div v-if="stuckDeals.length === 0" class="mc-empty">No bottlenecks detected.</div>
|
||||
<div v-else class="mc-stuck-list">
|
||||
<div
|
||||
v-for="deal in stuckDeals"
|
||||
:key="deal.id"
|
||||
class="mc-stuck-row"
|
||||
:style="{ background: severityBg(deal.daysAtStage) }"
|
||||
>
|
||||
<div class="mc-stuck-info">
|
||||
<span class="mc-stuck-name">{{ deal.customerName }}</span>
|
||||
<span class="mc-stuck-meta">
|
||||
<span
|
||||
class="mc-stuck-days"
|
||||
:style="{ color: severityColor(deal.daysAtStage) }"
|
||||
>
|
||||
{{ deal.daysAtStage }}d
|
||||
</span>
|
||||
at {{ stageLabelFor(deal.currentStage) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="mc-stuck-agent">{{ deal.agent }}</span>
|
||||
<button class="mc-btn-nudge" @click="onNudge(deal)">Nudge</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage Breakdown -->
|
||||
<div class="mc-card mc-section">
|
||||
<h2 class="mc-section-title">Stage Breakdown</h2>
|
||||
<div class="mc-tab-bar">
|
||||
<button
|
||||
v-for="stage in PIPELINE_STAGES"
|
||||
:key="stage.id"
|
||||
class="mc-tab"
|
||||
:class="{ 'mc-tab--active': activeStageTab === stage.id }"
|
||||
@click="activeStageTab = stage.id"
|
||||
>
|
||||
{{ stage.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="stageDeals.length === 0" class="mc-empty">
|
||||
<UIcon name="i-heroicons-inbox" style="width: 20px; height: 20px; color: #c0c0bc;" />
|
||||
<p>No deals at this stage</p>
|
||||
</div>
|
||||
<div v-else class="mc-table-wrap">
|
||||
<table class="mc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Product Line</th>
|
||||
<th>Days at Stage</th>
|
||||
<th>Agent</th>
|
||||
<th>Premium</th>
|
||||
<th>Form Completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="deal in stageDeals" :key="deal.id">
|
||||
<td class="mc-agent-name">{{ deal.customerName }}</td>
|
||||
<td>{{ deal.productLine }}</td>
|
||||
<td>
|
||||
<span :style="{ color: deal.daysAtStage > 5 ? severityColor(deal.daysAtStage) : 'inherit', fontWeight: deal.daysAtStage > 5 ? 600 : 400 }">
|
||||
{{ deal.daysAtStage }}d
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ deal.agent }}</td>
|
||||
<td>{{ formatCurrency(deal.premium) }}</td>
|
||||
<td>
|
||||
<div class="mc-progress-wrap">
|
||||
<div class="mc-progress-track">
|
||||
<div
|
||||
class="mc-progress-fill"
|
||||
:style="{ width: deal.formCompletion + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="mc-progress-label">{{ deal.formCompletion }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nudge confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="nudgeConfirmDeal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="cancelNudge">
|
||||
<div class="w-full max-w-sm rounded-xl border border-[var(--card-border)] bg-white p-6 shadow-xl">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style="background: rgba(1,105,111,0.08); color: #01696f;">
|
||||
<UIcon name="i-heroicons-bell-alert" style="width: 18px; height: 18px;" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Send nudge?</p>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
This will send a follow-up reminder to <span class="font-semibold text-[var(--text-primary)]">{{ nudgeConfirmDeal.customerName }}</span> about their pending deal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<UButton color="neutral" variant="soft" size="sm" @click="cancelNudge">Cancel</UButton>
|
||||
<UButton color="primary" size="sm" @click="confirmNudge">Send nudge</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Page ── */
|
||||
.mc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.mc-header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mc-title {
|
||||
font-size: 24px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mc-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8a8a86;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.mc-card {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.mc-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.mc-kpi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
.mc-kpi-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8a8a86;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-kpi-value {
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mc-kpi-value--bottleneck {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.mc-section {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.mc-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.mc-section-desc {
|
||||
font-size: 13px;
|
||||
color: #8a8a86;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Funnel ── */
|
||||
.mc-funnel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mc-funnel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 36px 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.mc-funnel-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mc-funnel-bar-track {
|
||||
height: 22px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-funnel-bar {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.4s ease;
|
||||
min-width: 4px;
|
||||
}
|
||||
.mc-funnel-count {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mc-funnel-pct {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.mc-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mc-table th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8a8a86;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
padding: 0 12px 10px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.mc-table td {
|
||||
padding: 10px 12px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mc-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mc-agent-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Badge ── */
|
||||
.mc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Stuck list ── */
|
||||
.mc-stuck-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.mc-stuck-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mc-stuck-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mc-stuck-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mc-stuck-meta {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
}
|
||||
.mc-stuck-days {
|
||||
font-weight: 600;
|
||||
}
|
||||
.mc-stuck-agent {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
min-width: 120px;
|
||||
}
|
||||
.mc-btn-nudge {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: #fff;
|
||||
color: #01696f;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mc-btn-nudge:hover {
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
}
|
||||
|
||||
/* ── Tab bar ── */
|
||||
.mc-tab-bar {
|
||||
display: inline-flex;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mc-tab {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b6b68;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.mc-tab--active {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ── Progress bar ── */
|
||||
.mc-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mc-progress-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 60px;
|
||||
}
|
||||
.mc-progress-fill {
|
||||
height: 100%;
|
||||
background: #01696f;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.mc-progress-label {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.mc-empty {
|
||||
font-size: 13px;
|
||||
color: #8a8a86;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.mc-kpi-strip {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.mc-kpi-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user