Files
policy-ui/app/pages/quotes/compare.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

1002 lines
33 KiB
Vue

<script setup lang="ts">
import type { QuoteComparativeView } from '~/types/quote-view-model'
import QuoteComparativeLayout from '~/components/quotes/QuoteComparativeLayout.vue'
definePageMeta({ ssr: false })
usePageTitle('Present Quotes')
const route = useRoute()
const router = useRouter()
/* ── Pipeline bar ── */
const { deals: allDeals } = useSalesPipeline()
const activeDealId = ref<string | null>(route.query.deal as string | null)
const activeDeals = computed(() => allDeals.value.filter(d => d.currentStage !== 'emission').slice(0, 10))
const pipelineDeal = computed(() => {
if (activeDealId.value) return allDeals.value.find(d => d.id === activeDealId.value) ?? null
return null
})
function onPipelineNavigate(stage: string) {
const stageRoutes: Record<string, string> = {
customer: '/quotes/new', get_quotes: '/quotes/new',
present_quotes: '/quotes/compare', solicitud: '/onboarding/solicitud', emission: '/onboarding/emissions',
}
if (stageRoutes[stage]) navigateTo(stageRoutes[stage])
}
/* ── Presentation mode ── */
type PresentMode = 'comparative' | 'single'
const presentMode = ref<PresentMode>('comparative')
const intakeSegment = computed(() => {
const s = route.query.segment
return typeof s === 'string' ? s : null
})
const fromAutoIntake = computed(() => route.query.from === 'auto')
const fromHealthIntake = computed(() => route.query.from === 'health')
const toast = useToast()
const { view, reset } = useQuoteSession()
const { enqueue } = useEmissionsQueue()
const pdfNote = ref('')
const pdfInputRef = ref<HTMLInputElement | null>(null)
function onPdfPick(e: Event) {
const f = (e.target as HTMLInputElement).files?.[0]
if (!f) return
if (f.type !== 'application/pdf') {
toast.add({ title: 'Please choose a PDF', color: 'error' })
return
}
pdfNote.value = `${f.name} (${(f.size / 1024).toFixed(1)} KB) — extraction not wired; use manual edit below.`
toast.add({
title: 'PDF attached',
description: 'Illustration parsing can populate the view model when the service is available.',
color: 'success'
})
}
function patchClient(field: keyof QuoteComparativeView['client'], value: string | number | boolean) {
view.value = {
...view.value,
client: { ...view.value.client, [field]: value }
}
}
function patchRequest(field: keyof QuoteComparativeView['request'], value: number | string) {
view.value = {
...view.value,
request: { ...view.value.request, [field]: value }
}
}
/* ── Single proposal mode ── */
const singleCarrierIdx = ref<number>(0)
const singleCarrier = computed(() => view.value.carriers[singleCarrierIdx.value] ?? view.value.carriers[0] ?? null)
/* ── Broker intake link (manual path) ── */
const bindToken = ref('')
function generateIntakeLink() {
const token = crypto.randomUUID?.() ?? String(Date.now())
bindToken.value = token
const url = `${typeof window !== 'undefined' ? window.location.origin : ''}/onboarding/solicitud?bind=${encodeURIComponent(token)}`
navigator.clipboard?.writeText(url).catch(() => {})
toast.add({
title: 'Intake link copied',
description: 'Share with the customer after they accept a quote.',
color: 'success'
})
}
/* ── Quote acceptance flow ── */
type AcceptanceState = 'pending' | 'selecting' | 'accepted'
const acceptState = ref<AcceptanceState>('pending')
const selectedCarrierIdx = ref<number | null>(null)
const acceptedAt = ref<string | null>(null)
const generatedEmissionIds = ref<string[]>([])
/** Which carrier the customer chose */
const acceptedCarrier = computed(() => {
if (selectedCarrierIdx.value === null) return null
return view.value.carriers[selectedCarrierIdx.value] ?? null
})
function beginAcceptance() {
acceptState.value = 'selecting'
selectedCarrierIdx.value = null
}
function cancelAcceptance() {
acceptState.value = 'pending'
selectedCarrierIdx.value = null
}
function confirmAcceptance() {
if (selectedCarrierIdx.value === null) {
toast.add({ title: 'Select the accepted option first', color: 'error' })
return
}
const carrier = view.value.carriers[selectedCarrierIdx.value]
if (!carrier) return
// Mark as accepted
acceptState.value = 'accepted'
acceptedAt.value = new Date().toISOString()
// Generate bind token
const token = crypto.randomUUID?.() ?? String(Date.now())
bindToken.value = token
// Auto-enqueue to emissions with source = 'auto'
const row = enqueue({
customerLabel: view.value.client.name || 'Customer',
insurerSlug: carrier.carrierName,
subRamoKey: 'life', // derived from quote context
productLine: carrier.productName,
bindToken: token,
source: 'auto',
carrierProduct: carrier.productName
})
generatedEmissionIds.value = [row.id]
toast.add({
title: 'Quote accepted — solicitud auto-generated',
description: `${carrier.productName} → emissions queue`,
color: 'success'
})
}
function goToEmissions() {
router.push('/onboarding/emissions')
}
</script>
<template>
<div class="cmp mx-auto max-w-5xl space-y-6 pb-12">
<!-- Back -->
<div class="flex items-center justify-between">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to overview</UButton>
</NuxtLink>
<UButton color="neutral" variant="soft" size="sm" @click="reset()">Reset demo data</UButton>
</div>
<!-- Sales flow indicator -->
<SalesFlowIndicator current-stage="present_quotes" />
<UAlert
v-if="fromAutoIntake"
color="info"
variant="soft"
title="Ficha de auto"
:description="
intakeSegment
? `Continúe el PDF comparativo — segmento: ${intakeSegment}. Los números de abajo se pueden alinear con la ficha cuando exista API.`
: 'Continúe el PDF comparativo con los datos capturados en cotización de auto.'
"
/>
<UAlert
v-if="fromHealthIntake"
color="info"
variant="soft"
title="Health comparative"
:description="
intakeSegment
? `Continue the comparative PDF — segment: ${intakeSegment}. Align premiums with census and age-band tables when your integration is available.`
: 'Continue the comparative PDF with data captured from the health quote flow.'
"
/>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Present Quotes</h1>
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
Build a single proposal or side-by-side comparative to present to the customer. Edit manually or attach a carrier PDF for future auto-fill.
</p>
</div>
</div>
<!-- Pipeline bar -->
<div v-if="activeDeals.length > 0">
<div v-if="!pipelineDeal" class="cmp-deal-picker">
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
<UIcon name="i-heroicons-arrow-path" style="width: 13px; height: 13px; opacity: 0.5;" />
<span class="font-medium">Continue an active deal:</span>
</div>
<div class="flex flex-wrap gap-2 mt-2">
<button v-for="d in activeDeals" :key="d.id" type="button" class="cmp-deal-chip" @click="activeDealId = d.id">
<span class="font-semibold">{{ d.customerName.split(' ').slice(0, 2).join(' ') }}</span>
<span style="font-size:10px;font-weight:600;padding:0 5px;border-radius:9999px;background:rgba(1,105,111,0.07);color:#01696f;">{{ d.productLine }}</span>
</button>
</div>
</div>
<template v-else>
<div class="flex items-center justify-between mb-1">
<span class="text-[11px] font-semibold uppercase tracking-wider text-[#8a8a86]">Active Deal</span>
<button type="button" class="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]" @click="activeDealId = null">Switch deal</button>
</div>
<SalesPipelineBar :deal="pipelineDeal" @navigate="onPipelineNavigate" />
</template>
</div>
<!-- Presentation mode toggle -->
<div class="cmp-mode-bar">
<div class="cmp-mode-tabs">
<button
type="button"
class="cmp-mode-tab"
:class="presentMode === 'comparative' ? 'cmp-mode-on' : 'cmp-mode-off'"
@click="presentMode = 'comparative'"
>
<UIcon name="i-heroicons-table-cells" style="width: 14px; height: 14px;" />
Comparative
</button>
<button
type="button"
class="cmp-mode-tab"
:class="presentMode === 'single' ? 'cmp-mode-on' : 'cmp-mode-off'"
@click="presentMode = 'single'"
>
<UIcon name="i-heroicons-document-text" style="width: 14px; height: 14px;" />
Single Proposal
</button>
</div>
<p class="text-[11px] text-[var(--text-muted)]">
{{ presentMode === 'comparative'
? `${view.carriers.length} carriers side-by-side`
: 'One carrier, detailed breakdown'
}}
</p>
</div>
<UCard>
<template #header>
<span class="font-semibold text-[var(--text-primary)]">Ingest</span>
</template>
<div class="flex flex-wrap items-end gap-4">
<div>
<input
ref="pdfInputRef"
type="file"
accept="application/pdf"
class="hidden"
@change="onPdfPick"
/>
<UButton
color="primary"
variant="soft"
icon="i-heroicons-document-arrow-up"
@click="pdfInputRef?.click()"
>
Attach illustration PDF
</UButton>
</div>
<p v-if="pdfNote" class="text-xs text-[var(--text-muted)]">{{ pdfNote }}</p>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<UFormField label="Cliente · nombre">
<UInput
:model-value="view.client.name"
class="w-full"
@update:model-value="patchClient('name', $event)"
/>
</UFormField>
<UFormField label="Edad">
<UInput
type="number"
:model-value="view.client.ageYears"
class="w-full"
@update:model-value="patchClient('ageYears', Number($event))"
/>
</UFormField>
<UFormField label="Suma asegurada (USD)">
<UInput
type="number"
:model-value="view.request.sumAssuredUsd"
class="w-full"
@update:model-value="patchRequest('sumAssuredUsd', Number($event))"
/>
</UFormField>
<UFormField label="Prima mensual (USD)">
<UInput
type="number"
:model-value="view.request.monthlyPremiumUsd"
class="w-full"
@update:model-value="patchRequest('monthlyPremiumUsd', Number($event))"
/>
</UFormField>
</div>
</UCard>
<!-- Comparative view -->
<div v-if="presentMode === 'comparative'" class="rounded-xl border border-[var(--card-border)] bg-[var(--surface)] p-4 shadow-sm md:p-8">
<QuoteComparativeLayout :model="view" />
</div>
<!-- Single proposal view -->
<div v-if="presentMode === 'single'" class="cmp-single-wrap">
<div class="cmp-single-head">
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86] mb-2">Select carrier to present</p>
<div class="cmp-single-options">
<button
v-for="(carrier, idx) in view.carriers"
:key="idx"
type="button"
class="cmp-single-option"
:class="(singleCarrierIdx ?? 0) === idx ? 'cmp-single-selected' : ''"
@click="singleCarrierIdx = idx"
>
<span class="text-[13px] font-semibold text-[var(--text-primary)]">{{ carrier.productName }}</span>
<span class="text-[11px] text-[var(--text-muted)]">{{ carrier.carrierName }}</span>
</button>
</div>
</div>
<!-- Single carrier detail card -->
<div v-if="singleCarrier" class="cmp-single-card">
<div class="cmp-single-card-head">
<div>
<p class="text-[14px] font-semibold text-[var(--text-primary)]">{{ singleCarrier.productName }}</p>
<p class="text-[12px] text-[var(--text-muted)]">{{ singleCarrier.carrierName }}</p>
</div>
<div class="text-right">
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86]">Sum assured</p>
<p class="text-[18px] font-semibold tabular-nums text-[var(--text-primary)]">${{ singleCarrier.sumAssuredUsd?.toLocaleString() }}</p>
</div>
</div>
<div class="cmp-single-client">
<div class="cmp-single-stat">
<span class="cmp-single-stat-label">Client</span>
<span class="cmp-single-stat-value">{{ view.client.name }}</span>
</div>
<div class="cmp-single-stat">
<span class="cmp-single-stat-label">Age</span>
<span class="cmp-single-stat-value">{{ view.client.ageYears }}</span>
</div>
<div class="cmp-single-stat">
<span class="cmp-single-stat-label">Monthly premium</span>
<span class="cmp-single-stat-value">${{ view.request.monthlyPremiumUsd?.toLocaleString() }}</span>
</div>
<div class="cmp-single-stat">
<span class="cmp-single-stat-label">Annual premium</span>
<span class="cmp-single-stat-value">${{ view.request.annualPremiumUsd?.toLocaleString() }}</span>
</div>
</div>
<div v-if="singleCarrier.ratesLine" class="cmp-single-rates">
<UIcon name="i-heroicons-information-circle" style="width: 14px; height: 14px; color: #01696f; flex-shrink: 0;" />
<span class="text-[12px] text-[var(--text-muted)]">{{ singleCarrier.ratesLine }}</span>
</div>
<!-- Projection table -->
<div v-if="singleCarrier.cells?.length" class="cmp-single-table-wrap">
<table class="cmp-single-table">
<thead>
<tr>
<th>Period</th>
<th>Age</th>
<th class="text-right">Guaranteed</th>
<th class="text-right">Projected</th>
</tr>
</thead>
<tbody>
<tr v-for="cell in singleCarrier.cells" :key="cell.yearLabel">
<td class="font-medium">{{ cell.yearLabel }}</td>
<td>{{ cell.ageLabel }}</td>
<td class="text-right tabular-nums">${{ cell.guaranteed?.toLocaleString() }}</td>
<td class="text-right tabular-nums font-medium" style="color: #01696f;">${{ cell.projected?.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="singleCarrier.highlightProjectedUsd" class="cmp-single-highlight">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86]">Projected value</p>
<p class="text-[22px] font-semibold tabular-nums" style="color: #01696f;">${{ singleCarrier.highlightProjectedUsd?.toLocaleString() }}</p>
</div>
<p v-if="singleCarrier.highlightNote" class="text-[12px] text-[var(--text-muted)] mt-1">{{ singleCarrier.highlightNote }}</p>
</div>
<div v-if="singleCarrier.footnote" class="cmp-single-footnote">
{{ singleCarrier.footnote }}
</div>
</div>
</div>
<!-- -->
<!-- ACCEPTANCE FLOW the core of the sales pipeline -->
<!-- -->
<div class="cmp-accept-section">
<div class="cmp-accept-head">
<div class="cmp-accept-icon" :class="acceptState === 'accepted' ? 'cmp-accept-icon-done' : ''">
<UIcon
:name="acceptState === 'accepted' ? 'i-heroicons-check-circle' : 'i-heroicons-hand-thumb-up'"
style="width: 20px; height: 20px;"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-[14px] font-semibold text-[var(--text-primary)]">
{{ acceptState === 'accepted' ? 'Quote accepted' : 'Customer acceptance' }}
</p>
<p class="mt-0.5 text-[13px] text-[var(--text-muted)]">
{{ acceptState === 'accepted'
? 'Solicitud auto-generated and sent to emissions queue.'
: 'When the customer confirms an option, mark it as accepted to auto-generate the solicitud and push it to emissions.'
}}
</p>
</div>
<span v-if="acceptState === 'accepted'" class="cmp-accept-badge-done">
<UIcon name="i-heroicons-check" style="width: 10px; height: 10px;" />
Accepted
</span>
</div>
<!-- State: pending just the button -->
<div v-if="acceptState === 'pending'" class="cmp-accept-body">
<div class="cmp-accept-flow-hint">
<div class="cmp-flow-step">
<span class="cmp-flow-num">1</span>
<span>Customer reviews comparative</span>
</div>
<div class="cmp-flow-arrow">
<UIcon name="i-heroicons-arrow-right" style="width: 12px; height: 12px;" />
</div>
<div class="cmp-flow-step">
<span class="cmp-flow-num">2</span>
<span>Customer accepts an option</span>
</div>
<div class="cmp-flow-arrow">
<UIcon name="i-heroicons-arrow-right" style="width: 12px; height: 12px;" />
</div>
<div class="cmp-flow-step">
<span class="cmp-flow-num">3</span>
<span>Solicitud auto-sent Emissions</span>
</div>
</div>
<div class="flex gap-2 pt-2">
<button type="button" class="cmp-accept-btn" @click="beginAcceptance">
<UIcon name="i-heroicons-hand-thumb-up" style="width: 14px; height: 14px;" />
Mark as accepted
</button>
</div>
</div>
<!-- State: selecting pick the carrier option -->
<div v-if="acceptState === 'selecting'" class="cmp-accept-body">
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86] mb-3">Which option did the customer accept?</p>
<div class="cmp-carrier-options">
<button
v-for="(carrier, idx) in view.carriers"
:key="idx"
type="button"
class="cmp-carrier-option"
:class="selectedCarrierIdx === idx ? 'cmp-carrier-selected' : ''"
@click="selectedCarrierIdx = idx"
>
<div class="cmp-carrier-radio">
<div v-if="selectedCarrierIdx === idx" class="cmp-carrier-radio-dot" />
</div>
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-[var(--text-primary)]">{{ carrier.productName }}</p>
<p class="text-[11px] text-[var(--text-muted)] truncate">{{ carrier.carrierName }}</p>
<div class="mt-1 flex items-center gap-3 text-[11px] text-[var(--text-muted)]">
<span>SA: ${{ carrier.sumAssuredUsd?.toLocaleString() }}</span>
<span v-if="carrier.highlightProjectedUsd">Proj: ${{ carrier.highlightProjectedUsd?.toLocaleString() }}</span>
</div>
</div>
<UIcon
v-if="selectedCarrierIdx === idx"
name="i-heroicons-check-circle"
style="width: 20px; height: 20px; color: #01696f; flex-shrink: 0;"
/>
</button>
</div>
<div class="flex gap-2 pt-3">
<button type="button" class="cmp-accept-btn" :class="selectedCarrierIdx === null ? 'cmp-btn-disabled' : ''" @click="confirmAcceptance">
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
Confirm acceptance & generate solicitud
</button>
<button type="button" class="cmp-cancel-btn" @click="cancelAcceptance">Cancel</button>
</div>
</div>
<!-- State: accepted confirmation -->
<div v-if="acceptState === 'accepted'" class="cmp-accept-body">
<div class="cmp-accepted-summary">
<div class="cmp-accepted-row">
<span class="cmp-accepted-label">Customer</span>
<span class="cmp-accepted-value">{{ view.client.name }}</span>
</div>
<div class="cmp-accepted-row">
<span class="cmp-accepted-label">Accepted product</span>
<span class="cmp-accepted-value">{{ acceptedCarrier?.productName }}</span>
</div>
<div class="cmp-accepted-row">
<span class="cmp-accepted-label">Carrier</span>
<span class="cmp-accepted-value">{{ acceptedCarrier?.carrierName }}</span>
</div>
<div class="cmp-accepted-row">
<span class="cmp-accepted-label">Accepted at</span>
<span class="cmp-accepted-value">{{ acceptedAt?.slice(0, 16).replace('T', ' · ') }}</span>
</div>
<div class="cmp-accepted-row">
<span class="cmp-accepted-label">Bind token</span>
<span class="cmp-accepted-value font-mono text-[11px]">{{ bindToken }}</span>
</div>
</div>
<div class="cmp-auto-sent-banner">
<UIcon name="i-heroicons-bolt" style="width: 16px; height: 16px; color: #01696f; flex-shrink: 0;" />
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-[var(--text-primary)]">Solicitud auto-generated</p>
<p class="text-[12px] text-[var(--text-muted)]">The required forms have been queued for emissions review. The customer will receive the solicitud documents for signature.</p>
</div>
</div>
<div class="flex flex-wrap gap-2 pt-2">
<button type="button" class="cmp-accept-btn" @click="goToEmissions">
<UIcon name="i-heroicons-queue-list" style="width: 14px; height: 14px;" />
View in emissions queue
</button>
<NuxtLink :to="`/onboarding/solicitud?bind=${bindToken}`">
<button type="button" class="cmp-cancel-btn">
<UIcon name="i-heroicons-pencil-square" style="width: 12px; height: 12px;" />
Open solicitud form
</button>
</NuxtLink>
</div>
</div>
</div>
<!-- Manual path (always available) -->
<div class="cmp-manual-card">
<div class="cmp-manual-head">
<UIcon name="i-heroicons-link" style="width: 16px; height: 16px; color: #8a8a86;" />
<span class="text-[13px] font-semibold text-[var(--text-primary)]">Manual solicitud path</span>
</div>
<div class="cmp-manual-body">
<p class="text-[13px] text-[var(--text-muted)]">
Need to handle things manually? Generate a broker intake link or go directly to the solicitud form. This path is always available regardless of acceptance status.
</p>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="cmp-accept-btn cmp-btn-neutral" @click="generateIntakeLink">
<UIcon name="i-heroicons-link" style="width: 14px; height: 14px;" />
Copy intake link
</button>
<NuxtLink to="/onboarding/solicitud">
<button type="button" class="cmp-cancel-btn">
<UIcon name="i-heroicons-arrow-right" style="width: 12px; height: 12px;" />
Open blank solicitud
</button>
</NuxtLink>
<NuxtLink v-if="bindToken && acceptState !== 'accepted'" :to="`/onboarding/solicitud?bind=${bindToken}`" target="_blank">
<button type="button" class="cmp-cancel-btn">Open with bind token</button>
</NuxtLink>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Deal picker ── */
.cmp-deal-picker {
padding: 12px 16px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.cmp-deal-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 8px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
font-size: 12px; color: var(--text-primary);
cursor: pointer; transition: all 150ms ease;
}
.cmp-deal-chip:hover { border-color: rgba(1,105,111,0.2); box-shadow: 0 1px 4px rgba(0,0,0,0.04); }
/* ── Acceptance section ── */
.cmp-accept-section {
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
.cmp-accept-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.cmp-accept-icon {
width: 36px; height: 36px;
border-radius: 10px;
background: rgba(1,105,111,0.06);
color: #01696f;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cmp-accept-icon-done {
background: rgba(15,123,95,0.08);
color: #0f7b5f;
}
.cmp-accept-body {
padding: 20px;
}
.cmp-accept-badge-done {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
background: rgba(15,123,95,0.08);
color: #0f7b5f;
white-space: nowrap;
}
/* ── Flow hint ── */
.cmp-accept-flow-hint {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 12px 14px;
border-radius: 8px;
background: rgba(0,0,0,0.015);
border: 1px solid rgba(0,0,0,0.04);
}
.cmp-flow-step {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.cmp-flow-num {
width: 20px; height: 20px;
border-radius: 50%;
background: rgba(1,105,111,0.08);
color: #01696f;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cmp-flow-arrow {
color: #c0c0bc;
display: flex;
align-items: center;
}
/* ── Carrier option cards ── */
.cmp-carrier-options {
display: grid;
gap: 8px;
}
.cmp-carrier-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
cursor: pointer;
transition: all 150ms ease;
text-align: left;
}
.cmp-carrier-option:hover {
border-color: rgba(1,105,111,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.cmp-carrier-selected {
border-color: #01696f;
background: rgba(1,105,111,0.015);
box-shadow: 0 0 0 1px #01696f;
}
.cmp-carrier-radio {
width: 18px; height: 18px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 150ms ease;
}
.cmp-carrier-selected .cmp-carrier-radio {
border-color: #01696f;
}
.cmp-carrier-radio-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #01696f;
}
/* ── Buttons ── */
.cmp-accept-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
background: #01696f;
color: #fff;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.cmp-accept-btn:hover { background: #015458; }
.cmp-btn-disabled {
opacity: 0.5;
pointer-events: none;
}
.cmp-btn-neutral {
background: #fff;
color: var(--text-primary);
border: 1px solid rgba(0,0,0,0.1);
}
.cmp-btn-neutral:hover {
border-color: rgba(0,0,0,0.2);
background: rgba(0,0,0,0.02);
}
.cmp-cancel-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 14px;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
border: 1px solid rgba(0,0,0,0.08);
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.cmp-cancel-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
/* ── Accepted summary ── */
.cmp-accepted-summary {
display: grid;
gap: 6px;
padding: 14px 16px;
border-radius: 8px;
background: rgba(15,123,95,0.02);
border: 1px solid rgba(15,123,95,0.08);
}
.cmp-accepted-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.cmp-accepted-label {
font-size: 11px;
font-weight: 500;
color: #8a8a86;
min-width: 120px;
flex-shrink: 0;
}
.cmp-accepted-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.cmp-auto-sent-banner {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
padding: 12px 14px;
border-radius: 8px;
background: rgba(1,105,111,0.03);
border: 1px solid rgba(1,105,111,0.1);
}
/* ── Manual card ── */
.cmp-manual-card {
border-radius: 12px;
border: 1px dashed rgba(0,0,0,0.1);
background: rgba(0,0,0,0.01);
overflow: hidden;
}
.cmp-manual-head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px dashed rgba(0,0,0,0.06);
}
.cmp-manual-body {
padding: 16px 20px;
}
/* ── Process note ── */
.cmp-process-note {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-radius: 10px;
background: rgba(124,58,237,0.03);
border: 1px solid rgba(124,58,237,0.1);
}
/* ── Mode toggle ── */
.cmp-mode-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.cmp-mode-tabs {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(0,0,0,0.04);
}
.cmp-mode-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.cmp-mode-on {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.cmp-mode-off {
background: transparent;
color: var(--text-muted);
}
.cmp-mode-off:hover { color: var(--text-primary); }
/* ── Single proposal ── */
.cmp-single-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.cmp-single-head {}
.cmp-single-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cmp-single-option {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
cursor: pointer;
transition: all 150ms ease;
text-align: left;
}
.cmp-single-option:hover {
border-color: rgba(1,105,111,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.cmp-single-selected {
border-color: #01696f;
background: rgba(1,105,111,0.015);
box-shadow: 0 0 0 1px #01696f;
}
.cmp-single-card {
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
.cmp-single-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.cmp-single-client {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: rgba(0,0,0,0.04);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
@media (max-width: 640px) { .cmp-single-client { grid-template-columns: repeat(2, 1fr); } }
.cmp-single-stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 20px;
background: #fff;
}
.cmp-single-stat-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
}
.cmp-single-stat-value {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.cmp-single-rates {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: rgba(1,105,111,0.02);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.cmp-single-table-wrap {
padding: 0;
overflow-x: auto;
}
.cmp-single-table {
width: 100%;
border-collapse: collapse;
}
.cmp-single-table th {
text-align: left;
padding: 10px 24px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.cmp-single-table td {
padding: 12px 24px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.cmp-single-table tr:last-child td { border-bottom: none; }
.cmp-single-table tr:hover td { background: rgba(0,0,0,0.01); }
.cmp-single-highlight {
padding: 20px 24px;
border-top: 1px solid rgba(0,0,0,0.06);
background: rgba(1,105,111,0.015);
}
.cmp-single-footnote {
padding: 12px 24px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid rgba(0,0,0,0.06);
background: rgba(0,0,0,0.01);
}
</style>