WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

1769
app/pages/policies/[id].vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -156,7 +156,7 @@ const applicantRows = computed(() => {
</UBadge>
<UBadge color="gray" variant="outline">CAR</UBadge>
</div>
<h1 class="text-2xl font-bold text-slate-900">{{ policy.applicant_display_name }}</h1>
<h1 class="text-2xl font-semibold text-[var(--text-primary)]">{{ policy.applicant_display_name }}</h1>
<p class="text-gray-500 text-sm font-mono">{{ policy.application_id }}</p>
</div>
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
@@ -168,7 +168,7 @@ const applicantRows = computed(() => {
<!-- Applicant dynamic rows based on client_type -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-user" class="w-4 h-4" />
{{ policy.client_type === 'juridico' ? 'Legal Entity' : 'Applicant' }}
<UBadge :color="clientTypeColor(policy.client_type)" variant="soft" size="xs">
@@ -187,7 +187,7 @@ const applicantRows = computed(() => {
<!-- Vehicle -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-truck" class="w-4 h-4" /> Vehicle
</p>
</template>
@@ -204,7 +204,7 @@ const applicantRows = computed(() => {
<!-- Issued policy -->
<UCard v-if="policy.policy_number">
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-check-badge" class="w-4 h-4 text-green-500" /> Policy
</p>
</template>
@@ -220,7 +220,7 @@ const applicantRows = computed(() => {
<!-- Providers -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Providers
<UBadge color="gray" variant="soft" size="xs">{{ policy.selected_providers?.length ?? 0 }}</UBadge>
</p>
@@ -243,7 +243,7 @@ const applicantRows = computed(() => {
<UCard v-if="quotes.length > 0">
<template #header>
<div class="flex justify-between items-center">
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-table-cells" class="w-4 h-4" /> Quote Comparison
<UBadge color="gray" variant="soft" size="xs">{{ allPlans.length }} plans</UBadge>
</p>
@@ -265,7 +265,7 @@ const applicantRows = computed(() => {
<UBadge v-if="plan.plan_id === policy.accepted_plan_id" color="green" variant="soft" size="xs">
Selected
</UBadge>
<p class="font-semibold text-slate-800">{{ plan.name }}</p>
<p class="font-semibold text-[var(--text-primary)]">{{ plan.name }}</p>
<p class="text-xs font-mono text-gray-400">{{ plan.provider_id?.slice(0, 8) }}...</p>
</div>
</th>
@@ -276,7 +276,7 @@ const applicantRows = computed(() => {
<td class="py-3 px-4 font-medium text-gray-600">Premium</td>
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
<span class="font-bold text-lg text-slate-900">${{ Number(plan.premium).toLocaleString() }}</span>
<span class="font-bold text-lg text-[var(--text-primary)]">${{ Number(plan.premium).toLocaleString() }}</span>
<span class="text-xs text-gray-400 block">/year</span>
</td>
</tr>
@@ -332,7 +332,7 @@ const applicantRows = computed(() => {
<UCard v-if="policy.solicitation_id">
<template #header>
<div class="flex justify-between items-center">
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-document-text" class="w-4 h-4" /> Solicitation Document
</p>
<div class="flex items-center gap-2">
@@ -377,7 +377,7 @@ const applicantRows = computed(() => {
<div class="flex flex-col h-full">
<div class="flex justify-between items-center p-6 border-b">
<div>
<h2 class="text-lg font-semibold text-slate-900">Accept Plan</h2>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Accept Plan</h2>
<p v-if="selectedPlan" class="text-sm text-gray-500">
{{ selectedPlan.name }} ${{ Number(selectedPlan.premium).toLocaleString() }}/yr
</p>
@@ -413,7 +413,7 @@ const applicantRows = computed(() => {
<!-- Optional solicitation fields -->
<div class="space-y-3">
<div class="flex justify-between items-center">
<p class="font-medium text-sm text-slate-700">Additional Fields</p>
<p class="font-medium text-sm text-[var(--text-primary)]">Additional Fields</p>
<UButton
icon="i-heroicons-plus" color="gray" variant="soft" size="xs"
@click="solicitationFields[`field_${Object.keys(solicitationFields).length + 1}`] = ''"

836
app/pages/policies/book.vue Normal file
View File

@@ -0,0 +1,836 @@
<script setup lang="ts">
import { MOCK_CUSTOMERS, fmtMoney, type MockPolicy } from '~/data/mock-customers'
definePageMeta({ ssr: false })
usePageTitle('Book of Business')
const { items } = useEmissionsQueue()
const inForce = computed(() => items.value.filter((x) => x.status === 'in_force'))
/* ── Flatten all mock policies with customer info ── */
type FlatPolicy = MockPolicy & { customerName: string; customerId: string }
const allPolicies = computed<FlatPolicy[]>(() => {
const rows: FlatPolicy[] = []
for (const cust of MOCK_CUSTOMERS) {
for (const pol of cust.policies) {
rows.push({ ...pol, customerName: cust.name, customerId: cust.id })
}
}
return rows
})
/* ── KPIs ── */
const totalGWP = computed(() => allPolicies.value.reduce((s, p) => s + p.premium, 0))
const activePolicies = computed(() => allPolicies.value.filter((p) => p.status === 'Active').length)
const retentionRate = computed(() => {
const total = allPolicies.value.length
const active = allPolicies.value.filter((p) => p.status === 'Active').length
return total > 0 ? Math.round((active / total) * 100) : 0
})
const avgPremium = computed(() => {
const count = allPolicies.value.length
return count > 0 ? Math.round(totalGWP.value / count) : 0
})
/* ── Line of business breakdown ── */
type LineRow = { line: string; count: number; totalPremium: number; pctOfBook: number; icon: string }
const lineBreakdown = computed<LineRow[]>(() => {
const map = new Map<string, { count: number; total: number; icon: string }>()
for (const pol of allPolicies.value) {
const existing = map.get(pol.line)
if (existing) {
existing.count++
existing.total += pol.premium
} else {
map.set(pol.line, { count: 1, total: pol.premium, icon: lineIcon(pol.line) })
}
}
const gwp = totalGWP.value || 1
const rows: LineRow[] = []
for (const [line, data] of map.entries()) {
rows.push({
line,
count: data.count,
totalPremium: data.total,
pctOfBook: Math.round((data.total / gwp) * 100),
icon: data.icon
})
}
rows.sort((a, b) => b.totalPremium - a.totalPremium)
return rows
})
/* ── Top carriers ── */
type CarrierRow = { carrier: string; count: number; gwp: number; pctShare: number }
const topCarriers = computed<CarrierRow[]>(() => {
const map = new Map<string, { count: number; gwp: number }>()
for (const pol of allPolicies.value) {
const existing = map.get(pol.carrier)
if (existing) {
existing.count++
existing.gwp += pol.premium
} else {
map.set(pol.carrier, { count: 1, gwp: pol.premium })
}
}
const gwp = totalGWP.value || 1
const rows: CarrierRow[] = []
for (const [carrier, data] of map.entries()) {
rows.push({
carrier,
count: data.count,
gwp: data.gwp,
pctShare: Math.round((data.gwp / gwp) * 100)
})
}
rows.sort((a, b) => b.gwp - a.gwp)
return rows
})
/* ── Recent activity ── */
type ActivityItem = { date: string; text: string; type: string; customerName: string }
const recentActivity = computed<ActivityItem[]>(() => {
const events: ActivityItem[] = []
for (const cust of MOCK_CUSTOMERS) {
for (const evt of cust.activity) {
if (evt.type === 'policy' || evt.type === 'renewal' || evt.type === 'claim') {
events.push({ ...evt, customerName: cust.name })
}
}
}
const order = ['Today', 'Yesterday']
events.sort((a, b) => {
const ai = order.indexOf(a.date)
const bi = order.indexOf(b.date)
if (ai >= 0 && bi >= 0) return ai - bi
if (ai >= 0) return -1
if (bi >= 0) return 1
return b.date.localeCompare(a.date)
})
return events.slice(0, 5)
})
/* ── Helpers ── */
function lineIcon(line: string) {
switch (line) {
case 'Auto': return 'i-heroicons-truck'
case 'Health': return 'i-heroicons-heart'
case 'Life': return 'i-heroicons-shield-check'
case 'Home': return 'i-heroicons-home-modern'
case 'Renter': return 'i-heroicons-home-modern'
case 'Umbrella': return 'i-heroicons-shield-exclamation'
default: return 'i-heroicons-document'
}
}
function lineColorClass(line: string) {
switch (line) {
case 'Auto': return 'bk-line-auto'
case 'Health': return 'bk-line-health'
case 'Life': return 'bk-line-life'
case 'Home': return 'bk-line-home'
case 'Renter': return 'bk-line-home'
case 'Umbrella': return 'bk-line-umbrella'
default: return 'bk-line-default'
}
}
function activityIcon(type: string) {
switch (type) {
case 'policy': return 'i-heroicons-document-check'
case 'renewal': return 'i-heroicons-arrow-path'
case 'claim': return 'i-heroicons-shield-exclamation'
default: return 'i-heroicons-document'
}
}
function activityColor(type: string) {
switch (type) {
case 'policy': return 'background: rgba(1,105,111,0.08); color: #01696f;'
case 'renewal': return 'background: rgba(245,158,11,0.08); color: #d97706;'
case 'claim': return 'background: rgba(244,63,94,0.08); color: #e11d48;'
default: return 'background: rgba(0,0,0,0.04); color: #8a8a86;'
}
}
/* ── Tab state ── */
const activeTab = ref<'overview' | 'carriers'>('overview')
</script>
<template>
<div class="bk-root mx-auto max-w-6xl space-y-6 pb-12">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Book of Business</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Consolidated view of all policies, lines, and carriers
</p>
</div>
<div class="flex items-center gap-2">
<NuxtLink to="/policies">
<button class="bk-btn-secondary">
<UIcon name="i-heroicons-arrow-left" class="w-3.5 h-3.5" />
All Policies
</button>
</NuxtLink>
</div>
</div>
<!-- Process note -->
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px; border-radius: 12px; background: rgba(147,51,234,0.05); border: 1px solid rgba(147,51,234,0.12);">
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; color: #9333ea; flex-shrink: 0; margin-top: 1px;" />
<div>
<p style="font-size: 13px; font-weight: 600; color: #7c3aed;">Cartera Global in development</p>
<p style="font-size: 12px; color: #8b5cf6; margin-top: 2px; line-height: 1.5;">Portfolio views, carrier breakdowns, retention analytics, and book-level reporting are actively being defined. Layout and feature scope may change.</p>
</div>
</div>
<!-- KPI strip -->
<div class="bk-kpi-strip">
<div class="bk-kpi-item">
<span class="bk-kpi-label">Total Book GWP</span>
<span class="bk-kpi-value">{{ fmtMoney(totalGWP) }}<span class="bk-kpi-suffix">/yr</span></span>
</div>
<div class="bk-kpi-divider" />
<div class="bk-kpi-item">
<span class="bk-kpi-label">Active Policies</span>
<span class="bk-kpi-value">{{ activePolicies }}</span>
</div>
<div class="bk-kpi-divider" />
<div class="bk-kpi-item">
<span class="bk-kpi-label">Retention Rate</span>
<span class="bk-kpi-value">{{ retentionRate }}%</span>
</div>
<div class="bk-kpi-divider" />
<div class="bk-kpi-item">
<span class="bk-kpi-label">Avg Premium</span>
<span class="bk-kpi-value">{{ fmtMoney(avgPremium) }}</span>
</div>
</div>
<!-- In-force from emissions queue (real data) -->
<div v-if="inForce.length > 0" class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>In-Force from Emissions</span>
<span class="bk-card-head-count">{{ inForce.length }}</span>
</div>
<div class="bk-table-wrap">
<table class="bk-table">
<thead>
<tr>
<th class="bk-th">Customer</th>
<th class="bk-th">Insurer</th>
<th class="bk-th">Product</th>
<th class="bk-th">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="row in inForce" :key="row.id" class="bk-row">
<td class="bk-td">
<span class="bk-text-primary">{{ row.customerLabel }}</span>
</td>
<td class="bk-td">
<span class="bk-text-muted">{{ row.insurerSlug }}</span>
</td>
<td class="bk-td">
<span class="bk-text-muted">{{ row.subRamoKey }} · {{ row.productLine }}</span>
</td>
<td class="bk-td">
<span class="bk-status-badge bk-status-active">In force</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tab toggle -->
<div class="bk-tab-container">
<button
class="bk-tab"
:class="{ 'bk-tab-active': activeTab === 'overview' }"
@click="activeTab = 'overview'"
>
Overview
</button>
<button
class="bk-tab"
:class="{ 'bk-tab-active': activeTab === 'carriers' }"
@click="activeTab = 'carriers'"
>
Carriers
</button>
</div>
<!-- Overview tab -->
<template v-if="activeTab === 'overview'">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Line of Business breakdown -->
<div class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>By Line of Business</span>
<span class="bk-card-head-count">{{ lineBreakdown.length }} lines</span>
</div>
<div class="bk-table-wrap">
<table class="bk-table">
<thead>
<tr>
<th class="bk-th">Line</th>
<th class="bk-th" style="text-align: center;">Policies</th>
<th class="bk-th" style="text-align: right;">Premium</th>
<th class="bk-th" style="text-align: right; width: 80px;">% of Book</th>
</tr>
</thead>
<tbody>
<tr v-for="row in lineBreakdown" :key="row.line" class="bk-row">
<td class="bk-td">
<div class="flex items-center gap-2.5">
<div class="bk-line-icon-wrap" :class="lineColorClass(row.line)">
<UIcon :name="row.icon" class="w-3.5 h-3.5" />
</div>
<span class="bk-text-primary" style="font-weight: 600;">{{ row.line }}</span>
</div>
</td>
<td class="bk-td" style="text-align: center;">
<span class="bk-text-muted">{{ row.count }}</span>
</td>
<td class="bk-td" style="text-align: right;">
<span class="bk-text-primary" style="font-weight: 600;">{{ fmtMoney(row.totalPremium) }}</span>
</td>
<td class="bk-td" style="text-align: right;">
<div class="bk-pct-cell">
<div class="bk-pct-bar">
<div class="bk-pct-bar-fill" :style="{ width: row.pctOfBook + '%' }" />
</div>
<span class="bk-pct-label">{{ row.pctOfBook }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recent Activity -->
<div class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>Recent Activity</span>
<span class="bk-card-head-count">Last 5</span>
</div>
<div class="bk-activity-list">
<div v-for="(evt, i) in recentActivity" :key="i" class="bk-activity-item">
<div class="bk-activity-icon" :style="activityColor(evt.type)">
<UIcon :name="activityIcon(evt.type)" class="w-3.5 h-3.5" />
</div>
<div class="bk-activity-body">
<p class="bk-activity-text">{{ evt.text }}</p>
<p class="bk-activity-meta">{{ evt.customerName }} · {{ evt.date }}</p>
</div>
</div>
<div v-if="recentActivity.length === 0" class="bk-empty-small">
<p>No recent policy activity</p>
</div>
</div>
</div>
</div>
<!-- Premium distribution visual -->
<div class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>Premium Distribution</span>
</div>
<div style="padding: 20px;">
<div class="bk-dist-bar">
<div
v-for="row in lineBreakdown"
:key="row.line"
class="bk-dist-segment"
:class="lineColorClass(row.line)"
:style="{ width: row.pctOfBook + '%' }"
:title="`${row.line}: ${row.pctOfBook}%`"
/>
</div>
<div class="bk-dist-legend">
<div v-for="row in lineBreakdown" :key="row.line" class="bk-dist-legend-item">
<span class="bk-dist-dot" :class="lineColorClass(row.line)" />
<span class="bk-dist-legend-label">{{ row.line }}</span>
<span class="bk-dist-legend-pct">{{ row.pctOfBook }}%</span>
</div>
</div>
</div>
</div>
</template>
<!-- Carriers tab -->
<template v-if="activeTab === 'carriers'">
<div class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>Top Carriers</span>
<span class="bk-card-head-count">{{ topCarriers.length }} carriers</span>
</div>
<div class="bk-table-wrap">
<table class="bk-table">
<thead>
<tr>
<th class="bk-th" style="width: 36px;">#</th>
<th class="bk-th">Carrier</th>
<th class="bk-th" style="text-align: center;">Policies</th>
<th class="bk-th" style="text-align: right;">GWP</th>
<th class="bk-th" style="text-align: right; width: 100px;">Share</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in topCarriers" :key="row.carrier" class="bk-row">
<td class="bk-td">
<span class="bk-rank">{{ idx + 1 }}</span>
</td>
<td class="bk-td">
<span class="bk-text-primary" style="font-weight: 600;">{{ row.carrier }}</span>
</td>
<td class="bk-td" style="text-align: center;">
<span class="bk-text-muted">{{ row.count }}</span>
</td>
<td class="bk-td" style="text-align: right;">
<span class="bk-text-primary" style="font-weight: 600;">{{ fmtMoney(row.gwp) }}</span>
</td>
<td class="bk-td" style="text-align: right;">
<div class="bk-pct-cell">
<div class="bk-pct-bar">
<div class="bk-pct-bar-fill" :style="{ width: row.pctShare + '%' }" />
</div>
<span class="bk-pct-label">{{ row.pctShare }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Carrier premium breakdown -->
<div class="bk-card bk-card-flush">
<div class="bk-card-head">
<span>Carrier Premium Share</span>
</div>
<div style="padding: 20px;">
<div class="bk-carrier-bars">
<div v-for="row in topCarriers" :key="row.carrier" class="bk-carrier-bar-row">
<span class="bk-carrier-bar-label">{{ row.carrier }}</span>
<div class="bk-carrier-bar-track">
<div class="bk-carrier-bar-fill" :style="{ width: row.pctShare + '%' }" />
</div>
<span class="bk-carrier-bar-value">{{ fmtMoney(row.gwp) }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* =====================================================================
BOOK OF BUSINESS — DESIGN SYSTEM (scoped, bk- prefix)
===================================================================== */
.bk-root {
--bk-brand: #01696f;
--bk-brand-soft: rgba(1, 105, 111, 0.06);
--bk-border: rgba(0, 0, 0, 0.06);
--bk-muted: #8a8a86;
}
/* ---- Card system ---- */
.bk-card {
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.bk-card-flush {
overflow: hidden;
}
.bk-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.bk-card-head-count {
font-size: 11px;
font-weight: 600;
color: #8a8a86;
background: rgba(0, 0, 0, 0.04);
padding: 2px 8px;
border-radius: 10px;
}
/* ---- KPI strip ---- */
.bk-kpi-strip {
display: flex;
align-items: center;
gap: 0;
background: #ffffff;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
padding: 16px 0;
}
.bk-kpi-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.bk-kpi-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
}
.bk-kpi-value {
font-size: 22px;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
letter-spacing: -0.01em;
}
.bk-kpi-suffix {
font-size: 13px;
font-weight: 500;
color: #8a8a86;
margin-left: 2px;
}
.bk-kpi-divider {
width: 1px;
height: 36px;
background: rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
/* ---- Buttons ---- */
.bk-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
cursor: pointer;
transition: all 150ms ease;
}
.bk-btn-secondary:hover {
border-color: rgba(1, 105, 111, 0.2);
color: #01696f;
}
/* ---- Tab toggle ---- */
.bk-tab-container {
display: inline-flex;
background: rgba(0, 0, 0, 0.04);
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.bk-tab {
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #8a8a86;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.bk-tab:hover {
color: var(--text-primary, #1a1a1a);
}
.bk-tab-active {
background: #ffffff;
color: var(--text-primary, #1a1a1a);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ---- Table ---- */
.bk-table-wrap {
overflow-x: auto;
}
.bk-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.bk-th {
padding: 10px 16px;
text-align: left;
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);
white-space: nowrap;
}
.bk-td {
padding: 12px 16px;
vertical-align: middle;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
}
.bk-row {
transition: background 120ms ease;
}
.bk-row:hover {
background: rgba(0, 0, 0, 0.015);
}
.bk-row:last-child .bk-td {
border-bottom: none;
}
/* ---- Text helpers ---- */
.bk-text-primary {
font-size: 13px;
color: var(--text-primary, #1a1a1a);
}
.bk-text-muted {
font-size: 13px;
color: var(--text-muted, #5c5650);
}
/* ---- Status badge ---- */
.bk-status-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.bk-status-active { background: rgba(16, 185, 129, 0.1); color: #059669; }
/* ---- Line icon wrap ---- */
.bk-line-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
flex-shrink: 0;
}
/* ---- Line colors (used for icons, dist segments, dots) ---- */
.bk-line-auto { background: rgba(59, 130, 246, 0.08); color: #2563eb; }
.bk-line-health { background: rgba(236, 72, 153, 0.08); color: #db2777; }
.bk-line-life { background: rgba(16, 185, 129, 0.08); color: #059669; }
.bk-line-home { background: rgba(245, 158, 11, 0.08); color: #d97706; }
.bk-line-umbrella { background: rgba(139, 92, 246, 0.08); color: #7c3aed; }
.bk-line-default { background: rgba(0, 0, 0, 0.04); color: #8a8a86; }
/* ---- Percentage cell ---- */
.bk-pct-cell {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.bk-pct-bar {
width: 48px;
height: 4px;
background: rgba(0, 0, 0, 0.05);
border-radius: 2px;
overflow: hidden;
}
.bk-pct-bar-fill {
height: 100%;
background: #01696f;
border-radius: 2px;
transition: width 300ms ease;
}
.bk-pct-label {
font-size: 11px;
font-weight: 600;
color: #8a8a86;
min-width: 28px;
text-align: right;
}
/* ---- Rank badge ---- */
.bk-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
font-size: 11px;
font-weight: 700;
color: #8a8a86;
}
/* ---- Distribution bar ---- */
.bk-dist-bar {
display: flex;
height: 10px;
border-radius: 5px;
overflow: hidden;
gap: 2px;
}
.bk-dist-segment {
height: 100%;
min-width: 4px;
border-radius: 3px;
transition: width 300ms ease;
}
/* Reuse line color classes for segment backgrounds */
.bk-dist-segment.bk-line-auto { background: #3b82f6; }
.bk-dist-segment.bk-line-health { background: #ec4899; }
.bk-dist-segment.bk-line-life { background: #10b981; }
.bk-dist-segment.bk-line-home { background: #f59e0b; }
.bk-dist-segment.bk-line-umbrella { background: #8b5cf6; }
.bk-dist-segment.bk-line-default { background: #8a8a86; }
.bk-dist-legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 14px;
}
.bk-dist-legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.bk-dist-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
/* Dot colors match segment colors */
.bk-dist-dot.bk-line-auto { background: #3b82f6; }
.bk-dist-dot.bk-line-health { background: #ec4899; }
.bk-dist-dot.bk-line-life { background: #10b981; }
.bk-dist-dot.bk-line-home { background: #f59e0b; }
.bk-dist-dot.bk-line-umbrella { background: #8b5cf6; }
.bk-dist-dot.bk-line-default { background: #8a8a86; }
.bk-dist-legend-label {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
}
.bk-dist-legend-pct {
font-size: 11px;
font-weight: 600;
color: #8a8a86;
}
/* ---- Carrier horizontal bars ---- */
.bk-carrier-bars {
display: flex;
flex-direction: column;
gap: 12px;
}
.bk-carrier-bar-row {
display: flex;
align-items: center;
gap: 12px;
}
.bk-carrier-bar-label {
width: 120px;
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
text-align: right;
}
.bk-carrier-bar-track {
flex: 1;
height: 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
overflow: hidden;
}
.bk-carrier-bar-fill {
height: 100%;
background: linear-gradient(90deg, #01696f, #018f97);
border-radius: 4px;
transition: width 300ms ease;
}
.bk-carrier-bar-value {
width: 80px;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-muted, #5c5650);
}
/* ---- Activity list ---- */
.bk-activity-list {
display: flex;
flex-direction: column;
}
.bk-activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
transition: background 120ms ease;
}
.bk-activity-item:hover {
background: rgba(0, 0, 0, 0.01);
}
.bk-activity-item:last-child {
border-bottom: none;
}
.bk-activity-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
flex-shrink: 0;
}
.bk-activity-body {
flex: 1;
min-width: 0;
}
.bk-activity-text {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
line-height: 1.4;
}
.bk-activity-meta {
font-size: 11px;
color: #8a8a86;
margin-top: 2px;
}
/* ---- Empty small ---- */
.bk-empty-small {
padding: 32px 20px;
text-align: center;
font-size: 13px;
color: #8a8a86;
}
</style>

View File

@@ -0,0 +1,965 @@
<script setup lang="ts">
definePageMeta({ ssr: false })
usePageTitle('Colectivos · Cartera')
const { accounts, activeAccounts, totalMembers, totalDependents, totalPremium } = useColectivos()
/* ── Filters & sort ── */
const search = ref('')
const viewMode = ref<'card' | 'list'>('card')
const lobFilter = ref<string>('all')
const statusFilter = ref<string>('all')
const carrierFilter = ref<string>('all')
const agentFilter = ref<string>('all')
const sortBy = ref<string>('premium_desc')
const lobOptions = [
{ label: 'All LOBs', value: 'all' },
{ label: 'Health', value: 'Health' },
{ label: 'Life', value: 'Life' },
{ label: 'Disability', value: 'Disability' },
]
const statusOptions = [
{ label: 'All statuses', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Onboarding', value: 'onboarding' },
{ label: 'Renewal Due', value: 'renewal_due' },
{ label: 'Suspended', value: 'suspended' },
]
const sortOptions = [
{ label: 'Premium (high → low)', value: 'premium_desc' },
{ label: 'Members (high → low)', value: 'members_desc' },
{ label: 'Renewal date', value: 'renewal' },
{ label: 'Alphabetical', value: 'alpha' },
]
const carrierOptions = computed(() => [
{ label: 'All Carriers', value: 'all' },
...([...new Set(accounts.value.map(a => a.carrier))].sort().map(c => ({ label: c, value: c })))
])
const agentOptions = computed(() => [
{ label: 'All Agents', value: 'all' },
...([...new Set(accounts.value.map(a => a.agent))].sort().map(a => ({ label: a, value: a })))
])
/* ── Derived data ── */
const onboardingCount = computed(() =>
accounts.value.filter(a => a.status === 'onboarding').length,
)
const renewalDueCount = computed(() =>
accounts.value.filter(a => a.status === 'renewal_due').length,
)
const suspendedCount = computed(() =>
accounts.value.filter(a => a.status === 'suspended').length,
)
const totalMonthlyPremium = computed(() =>
accounts.value.reduce((s, a) => s + a.monthlyPremium, 0),
)
const avgCommission = computed(() => {
if (!accounts.value.length) return 0
return accounts.value.reduce((s, a) => s + a.commissionPct, 0) / accounts.value.length
})
const filtered = computed(() => {
let rows = [...accounts.value]
if (lobFilter.value !== 'all') rows = rows.filter(a => a.lob === lobFilter.value)
if (statusFilter.value !== 'all') rows = rows.filter(a => a.status === statusFilter.value)
if (carrierFilter.value !== 'all') rows = rows.filter(a => a.carrier === carrierFilter.value)
if (agentFilter.value !== 'all') rows = rows.filter(a => a.agent === agentFilter.value)
const q = search.value.trim().toLowerCase()
if (q) {
rows = rows.filter(a =>
a.name.toLowerCase().includes(q) ||
a.carrier.toLowerCase().includes(q) ||
a.product.toLowerCase().includes(q),
)
}
switch (sortBy.value) {
case 'premium_desc': rows.sort((a, b) => b.annualPremium - a.annualPremium); break
case 'members_desc': rows.sort((a, b) => b.totalMembers - a.totalMembers); break
case 'renewal': rows.sort((a, b) => a.renewalDate.localeCompare(b.renewalDate)); break
case 'alpha': rows.sort((a, b) => a.name.localeCompare(b.name)); break
}
return rows
})
const filteredTotalAnnual = computed(() =>
filtered.value.reduce((s, a) => s + a.annualPremium, 0),
)
const filteredTotalMembers = computed(() =>
filtered.value.reduce((s, a) => s + a.totalMembers, 0),
)
const filteredTotalDependents = computed(() =>
filtered.value.reduce((s, a) => s + a.dependentsCount, 0),
)
/* ── Portfolio health segments ── */
const healthSegments = computed(() => {
const total = accounts.value.length || 1
const active = activeAccounts.value.length
const onboarding = onboardingCount.value
const renewal = renewalDueCount.value
const suspended = suspendedCount.value
return [
{ label: 'Active', count: active, pct: (active / total) * 100, color: '#16a34a' },
{ label: 'Onboarding', count: onboarding, pct: (onboarding / total) * 100, color: '#3b82f6' },
{ label: 'Renewal Due', count: renewal, pct: (renewal / total) * 100, color: '#f59e0b' },
{ label: 'Suspended', count: suspended, pct: (suspended / total) * 100, color: '#dc2626' },
]
})
/* ── KPI cards config ── */
const kpiCards = computed(() => [
{
label: 'Total Groups',
value: accounts.value.length.toString(),
icon: 'i-heroicons-building-office-2',
iconBg: 'rgba(1,105,111,0.08)',
iconColor: '#01696f',
accent: '',
},
{
label: 'Active Groups',
value: activeAccounts.value.length.toString(),
icon: 'i-heroicons-check-badge',
iconBg: 'rgba(22,163,74,0.08)',
iconColor: '#16a34a',
accent: '',
},
{
label: 'Total Members',
value: totalMembers.value.toLocaleString(),
icon: 'i-heroicons-users',
iconBg: 'rgba(59,130,246,0.08)',
iconColor: '#3b82f6',
accent: '',
},
{
label: 'Total Dependents',
value: totalDependents.value.toLocaleString(),
icon: 'i-heroicons-user-plus',
iconBg: 'rgba(139,92,246,0.08)',
iconColor: '#8b5cf6',
accent: '',
},
{
label: 'Annual Premium',
value: fmtCurrency(totalPremium.value),
icon: 'i-heroicons-banknotes',
iconBg: 'rgba(1,105,111,0.08)',
iconColor: '#01696f',
accent: 'gc-kpi--teal',
},
{
label: 'Monthly Premium',
value: fmtCurrency(totalMonthlyPremium.value),
icon: 'i-heroicons-calendar-days',
iconBg: 'rgba(1,105,111,0.08)',
iconColor: '#01696f',
accent: 'gc-kpi--teal',
},
{
label: 'Renewals Due',
value: renewalDueCount.value.toString(),
icon: 'i-heroicons-clock',
iconBg: 'rgba(245,158,11,0.08)',
iconColor: '#f59e0b',
accent: 'gc-kpi--amber',
},
{
label: 'Onboarding',
value: onboardingCount.value.toString(),
icon: 'i-heroicons-arrow-path',
iconBg: 'rgba(59,130,246,0.08)',
iconColor: '#3b82f6',
accent: '',
},
{
label: 'Avg Commission',
value: avgCommission.value.toFixed(1) + '%',
icon: 'i-heroicons-chart-bar',
iconBg: 'rgba(139,92,246,0.08)',
iconColor: '#8b5cf6',
accent: '',
},
])
/* ── Helpers ── */
function fmtCurrency(n: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n)
}
function fmtDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function lobColor(lob: string) {
switch (lob) {
case 'Health': return 'success'
case 'Life': return 'info'
case 'Disability': return 'warning'
default: return 'neutral'
}
}
function statusBadge(s: string) {
switch (s) {
case 'active': return { label: 'Active', color: 'success' as const }
case 'onboarding': return { label: 'Onboarding', color: 'info' as const }
case 'renewal_due': return { label: 'Renewal Due', color: 'warning' as const }
case 'quoting': return { label: 'Quoting', color: 'neutral' as const }
case 'suspended': return { label: 'Suspended', color: 'error' as const }
case 'cancelled': return { label: 'Cancelled', color: 'neutral' as const }
default: return { label: s, color: 'neutral' as const }
}
}
function renewalClass(d: string) {
const diff = (new Date(d).getTime() - Date.now()) / 86_400_000
if (diff < 0) return 'gc-renewal--overdue'
if (diff <= 30) return 'gc-renewal--urgent'
if (diff <= 90) return 'gc-renewal--soon'
return ''
}
function renewalDotClass(d: string) {
const diff = (new Date(d).getTime() - Date.now()) / 86_400_000
if (diff < 0) return 'gc-rdot gc-rdot--red'
if (diff <= 30) return 'gc-rdot gc-rdot--orange'
if (diff <= 90) return 'gc-rdot gc-rdot--amber'
return ''
}
function initials(name: string) {
return name.split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
}
function initialsColor(name: string) {
const colors = ['#01696f', '#3b82f6', '#8b5cf6', '#16a34a', '#ea580c', '#dc2626', '#ca8a04']
let hash = 0
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
return colors[Math.abs(hash) % colors.length]
}
</script>
<template>
<div class="gc-page">
<!-- Header -->
<div class="gc-header">
<div class="gc-header-left">
<h1 class="gc-title">Colectivos</h1>
<span class="gc-count-badge">{{ filtered.length }}</span>
</div>
<div class="gc-header-right">
<NuxtLink to="/support/collectivos" class="gc-header-link">
<UIcon name="i-heroicons-cog-6-tooth" class="gc-header-link-icon" />
Go to Operations
</NuxtLink>
</div>
</div>
<!-- Filter bar -->
<div class="gc-filters">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Search group name, carrier, product..."
class="gc-filter-search"
/>
<USelect v-model="lobFilter" :items="lobOptions" value-key="value" label-key="label" class="gc-filter-select" />
<USelect v-model="statusFilter" :items="statusOptions" value-key="value" label-key="label" class="gc-filter-select" />
<USelect v-model="carrierFilter" :items="carrierOptions" value-key="value" label-key="label" class="gc-filter-select" />
<USelect v-model="agentFilter" :items="agentOptions" value-key="value" label-key="label" class="gc-filter-select" />
<USelect v-model="sortBy" :items="sortOptions" value-key="value" label-key="label" class="gc-filter-select" />
<div class="gc-view-toggle">
<button type="button" :class="['gc-view-toggle-btn', viewMode === 'card' && 'gc-view-toggle-btn--active']" title="Card view" @click="viewMode = 'card'">
<UIcon name="i-heroicons-squares-2x2" style="width: 16px; height: 16px;" />
</button>
<button type="button" :class="['gc-view-toggle-btn', viewMode === 'list' && 'gc-view-toggle-btn--active']" title="List view" @click="viewMode = 'list'">
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
</button>
</div>
</div>
<!-- Card View -->
<div v-if="viewMode === 'card'" class="gc-card-grid">
<NuxtLink
v-for="a in filtered"
:key="a.id"
:to="`/support/collectivos/${a.id}`"
class="gc-card-item"
>
<div class="gc-card-item__top">
<span class="gc-avatar" :style="{ background: initialsColor(a.name) + '12', color: initialsColor(a.name) }">{{ initials(a.name) }}</span>
<div style="flex: 1; min-width: 0;">
<p class="gc-card-item__name">{{ a.name }}</p>
<span class="gc-ruc">{{ a.ruc }}</span>
</div>
<span class="gc-lob-pill" :class="'gc-lob--' + lobColor(a.lob)">{{ a.lob }}</span>
</div>
<div class="gc-card-item__body">
<div class="gc-card-item__field">
<span class="gc-card-item__label">Carrier</span>
<span class="gc-card-item__value">{{ a.carrier }}</span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Members</span>
<span class="gc-card-item__value">{{ a.totalMembers.toLocaleString() }} <span class="gc-dependents">({{ a.dependentsCount }})</span></span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Annual Premium</span>
<span class="gc-card-item__value" style="font-weight: 600;">{{ fmtCurrency(a.annualPremium) }}</span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Commission</span>
<span class="gc-card-item__value">{{ a.commissionPct }}%</span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Status</span>
<span class="gc-status-pill" :class="'gc-status--' + statusBadge(a.status).color">{{ statusBadge(a.status).label }}</span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Renewal</span>
<span :class="renewalClass(a.renewalDate)">{{ fmtDate(a.renewalDate) }}</span>
</div>
<div class="gc-card-item__field">
<span class="gc-card-item__label">Agent</span>
<span class="gc-card-item__value">{{ a.agent }}</span>
</div>
</div>
<div v-if="a.hasUrgentIssues" class="gc-card-item__urgent">
<UIcon name="i-heroicons-exclamation-triangle" style="width: 12px; height: 12px;" />
Urgent issues
</div>
</NuxtLink>
<div v-if="filtered.length === 0" class="gc-empty" style="grid-column: 1 / -1;">No group accounts match your filters.</div>
</div>
<!-- List View -->
<div v-else class="gc-table-wrap">
<table class="gc-table">
<thead>
<tr>
<th class="gc-th gc-th--left">Group Name</th>
<th class="gc-th gc-th--left">LOB</th>
<th class="gc-th gc-th--left gc-hide-mobile">Carrier</th>
<th class="gc-th gc-th--right">Members</th>
<th class="gc-th gc-th--right">Annual Premium</th>
<th class="gc-th gc-th--right gc-hide-tablet">Monthly Premium</th>
<th class="gc-th gc-th--right gc-hide-tablet">Comm %</th>
<th class="gc-th gc-th--left">Status</th>
<th class="gc-th gc-th--left gc-hide-mobile">Renewal</th>
<th class="gc-th gc-th--left gc-hide-tablet">Agent</th>
<th class="gc-th gc-th--center" title="Issues">
<UIcon name="i-heroicons-exclamation-triangle" class="gc-th-icon" />
</th>
</tr>
</thead>
<tbody>
<tr v-for="a in filtered" :key="a.id" class="gc-row">
<td class="gc-td">
<div class="gc-group-cell">
<span
class="gc-avatar"
:style="{ background: initialsColor(a.name) + '12', color: initialsColor(a.name) }"
>{{ initials(a.name) }}</span>
<div>
<NuxtLink :to="`/support/collectivos/${a.id}`" class="gc-group-link">
{{ a.name }}
</NuxtLink>
<span class="gc-ruc">{{ a.ruc }}</span>
</div>
</div>
</td>
<td class="gc-td">
<span class="gc-lob-pill" :class="'gc-lob--' + lobColor(a.lob)">{{ a.lob }}</span>
</td>
<td class="gc-td gc-hide-mobile">{{ a.carrier }}</td>
<td class="gc-td gc-td--num">
{{ a.totalMembers.toLocaleString() }}
<span class="gc-dependents">({{ a.dependentsCount }})</span>
</td>
<td class="gc-td gc-td--num gc-td--premium">{{ fmtCurrency(a.annualPremium) }}</td>
<td class="gc-td gc-td--num gc-hide-tablet">{{ fmtCurrency(a.monthlyPremium) }}</td>
<td class="gc-td gc-td--num gc-hide-tablet">{{ a.commissionPct }}%</td>
<td class="gc-td">
<span class="gc-status-pill" :class="'gc-status--' + statusBadge(a.status).color">
{{ statusBadge(a.status).label }}
</span>
</td>
<td class="gc-td gc-hide-mobile" :class="renewalClass(a.renewalDate)">
<span :class="renewalDotClass(a.renewalDate)" />
{{ fmtDate(a.renewalDate) }}
</td>
<td class="gc-td gc-hide-tablet">{{ a.agent }}</td>
<td class="gc-td gc-td--center">
<span
v-if="a.hasUrgentIssues"
class="gc-dot gc-dot--red"
:title="`Urgent issues on ${a.name}`"
/>
<span
v-else-if="a.pendingTasks > 0"
class="gc-dot gc-dot--amber"
:title="`${a.pendingTasks} pending task${a.pendingTasks !== 1 ? 's' : ''} on ${a.name}`"
/>
<span v-else class="gc-dot gc-dot--clear" />
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="11" class="gc-empty">No group accounts match your filters.</td>
</tr>
</tbody>
</table>
</div>
<!-- 6. Bottom Summary Bar -->
<div class="gc-bottom-bar">
<div class="gc-bottom-inner">
<span class="gc-bottom-item">
<UIcon name="i-heroicons-table-cells" class="gc-bottom-icon" />
{{ filtered.length }} group{{ filtered.length !== 1 ? 's' : '' }} shown
</span>
<span class="gc-bottom-sep" />
<span class="gc-bottom-item">
Annual premium: <strong>{{ fmtCurrency(filteredTotalAnnual) }}</strong>
</span>
<span class="gc-bottom-sep" />
<span class="gc-bottom-item">
Members + dependents: <strong>{{ (filteredTotalMembers + filteredTotalDependents).toLocaleString() }}</strong>
</span>
</div>
</div>
<!-- 7. Cross-links -->
<div class="gc-crosslinks">
<NuxtLink to="/support/collectivos" class="gc-crosslink">
Go to Operations
<UIcon name="i-heroicons-arrow-right" class="gc-crosslink-icon" />
</NuxtLink>
<NuxtLink to="/policies" class="gc-crosslink">
View All Policies
<UIcon name="i-heroicons-arrow-right" class="gc-crosslink-icon" />
</NuxtLink>
</div>
</div>
</template>
<style scoped>
/* ── gc- prefix: group cartera scoped styles ── */
.gc-page {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 48px;
max-width: 80rem;
margin: 0 auto;
}
/* ── Header ── */
.gc-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.gc-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.gc-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.gc-count-badge {
font-size: 11px;
font-weight: 700;
color: #01696f;
background: rgba(1,105,111,0.08);
padding: 2px 9px;
border-radius: 10px;
}
.gc-title {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
line-height: 1;
}
.gc-header-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: #01696f;
text-decoration: none;
white-space: nowrap;
transition: all 150ms ease;
}
.gc-header-link:hover { text-decoration: underline; }
.gc-header-link-icon {
width: 14px;
height: 14px;
}
/* ── View toggle ── */
.gc-view-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.04);
margin-left: auto;
}
.gc-view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: #8a8a86;
cursor: pointer;
transition: all 0.15s ease;
}
.gc-view-toggle-btn:hover { color: var(--text-primary); }
.gc-view-toggle-btn--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── Card grid ── */
.gc-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
@media (max-width: 1023px) { .gc-card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 639px) { .gc-card-grid { grid-template-columns: 1fr; } }
.gc-card-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
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);
text-decoration: none;
transition: all 0.15s ease;
cursor: pointer;
}
.gc-card-item:hover {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.07);
border-color: rgba(1, 105, 111, 0.15);
}
.gc-card-item__top {
display: flex;
align-items: center;
gap: 10px;
}
.gc-card-item__name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gc-card-item__body {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
.gc-card-item__field {
display: flex;
justify-content: space-between;
align-items: center;
}
.gc-card-item__label {
font-size: 12px;
color: #8a8a86;
}
.gc-card-item__value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.gc-card-item__urgent {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: #dc2626;
padding: 4px 8px;
background: rgba(239, 68, 68, 0.06);
border-radius: 6px;
margin-top: 2px;
}
/* ── Filters ── */
.gc-filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.gc-filter-search {
flex: 1 1 220px;
min-width: 180px;
}
.gc-filter-select {
width: 100%;
max-width: 180px;
}
@media (max-width: 639px) {
.gc-filter-select { max-width: 100%; }
}
/* ── Table ── */
.gc-table-wrap {
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);
overflow-x: auto;
}
.gc-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.gc-th {
padding: 10px 14px;
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);
background: rgba(0, 0, 0, 0.015);
white-space: nowrap;
}
.gc-th--left { text-align: left; }
.gc-th--right { text-align: right; }
.gc-th--center { text-align: center; }
.gc-th-icon {
width: 13px;
height: 13px;
color: #8a8a86;
}
.gc-row {
transition: all 150ms ease;
}
.gc-row:hover {
background: rgba(1, 105, 111, 0.03);
}
.gc-row:not(:last-child) .gc-td {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.gc-td {
padding: 10px 14px;
vertical-align: middle;
color: var(--text-primary);
}
.gc-td--num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.gc-td--premium {
font-weight: 600;
}
.gc-td--center {
text-align: center;
}
/* ── Group name cell with avatar ── */
.gc-group-cell {
display: flex;
align-items: center;
gap: 10px;
}
.gc-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
}
.gc-group-link {
font-weight: 600;
color: #01696f;
text-decoration: none;
display: block;
line-height: 1.3;
transition: all 150ms ease;
}
.gc-group-link:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.gc-ruc {
display: block;
font-size: 11px;
color: #8a8a86;
margin-top: 1px;
}
.gc-dependents {
color: #8a8a86;
font-size: 11px;
margin-left: 2px;
}
/* ── Custom pill badges (LOB) ── */
.gc-lob-pill {
display: inline-block;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 9999px;
white-space: nowrap;
line-height: 1.6;
}
.gc-lob--success {
color: #16a34a;
background: rgba(22, 163, 74, 0.1);
}
.gc-lob--info {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.gc-lob--warning {
color: #ca8a04;
background: rgba(202, 138, 4, 0.1);
}
.gc-lob--neutral {
color: #8a8a86;
background: rgba(0, 0, 0, 0.06);
}
/* ── Custom pill badges (Status) ── */
.gc-status-pill {
display: inline-block;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 9999px;
white-space: nowrap;
line-height: 1.6;
}
.gc-status--success {
color: #16a34a;
background: rgba(22, 163, 74, 0.1);
}
.gc-status--info {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.gc-status--warning {
color: #ca8a04;
background: rgba(202, 138, 4, 0.1);
}
.gc-status--error {
color: #dc2626;
background: rgba(220, 38, 38, 0.1);
}
.gc-status--neutral {
color: #8a8a86;
background: rgba(0, 0, 0, 0.06);
}
/* ── Renewal color-coding ── */
.gc-renewal--overdue { color: #dc2626; font-weight: 600; }
.gc-renewal--urgent { color: #ea580c; font-weight: 600; }
.gc-renewal--soon { color: #ca8a04; }
/* ── Renewal date dots ── */
.gc-rdot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
}
.gc-rdot--red { background: #dc2626; }
.gc-rdot--orange { background: #ea580c; }
.gc-rdot--amber { background: #f59e0b; }
/* ── Issues dot ── */
.gc-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
cursor: default;
}
.gc-dot--red { background: #dc2626; }
.gc-dot--amber { background: #f59e0b; }
.gc-dot--clear { background: transparent; }
/* ── Empty state ── */
.gc-empty {
padding: 40px 14px;
text-align: center;
color: #8a8a86;
font-size: 14px;
}
/* ── Responsive hide classes ── */
@media (max-width: 767px) {
.gc-hide-mobile { display: none; }
}
@media (max-width: 1023px) {
.gc-hide-tablet { display: none; }
}
/* ── Bottom Summary Bar ── */
.gc-bottom-bar {
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
padding: 14px 20px;
}
.gc-bottom-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.gc-bottom-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: var(--text-muted);
}
.gc-bottom-item strong {
color: var(--text-primary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.gc-bottom-icon {
width: 14px;
height: 14px;
color: #8a8a86;
}
.gc-bottom-sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: #d4d4d0;
flex-shrink: 0;
}
/* ── Cross-links ── */
.gc-crosslinks {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.gc-crosslink {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: #01696f;
text-decoration: none;
transition: all 150ms ease;
}
.gc-crosslink:hover { text-decoration: underline; }
.gc-crosslink-icon {
width: 14px;
height: 14px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@ const selectedCustomer = ref<any>(null)
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
query: computed(() => ({
'page_size': 12,
'page': customerPage.value,
'page[number]': customerPage.value,
'page[size]': 12,
...(debouncedCustomerSearch.value && {
'filters[0][field]': 'search',
'filters[0][op]': '==',
@@ -176,7 +176,7 @@ async function submitCarPolicy() {
}) as any
toast.add({ title: 'Policy submitted successfully', color: 'green' })
router.push(`/policies/${data.application_id}`)
router.push(`/policies/app/${data.application_id}`)
} catch (e: any) {
toast.add({
title: 'Failed to submit policy',
@@ -214,15 +214,15 @@ const isCarFormValid = computed(() => {
</UButton>
</NuxtLink>
<div>
<h1 class="text-3xl text-slate-900 font-bold">New Policy</h1>
<p class="text-gray-500 text-sm">Submit a new insurance policy quote request</p>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Policy</h1>
<p class="text-[13px] text-[var(--text-muted)]">Submit a new insurance policy quote request</p>
</div>
</div>
<!-- Customer Selection -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-users" class="w-4 h-4" />
Select Customer
</p>
@@ -248,13 +248,13 @@ const isCarFormValid = computed(() => {
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
:class="selectedCustomer?.id === c.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 bg-white'"
: 'border-gray-200 hover:border-gray-300 bg-[var(--surface)]'"
@click="selectCustomer(c)"
>
<UAvatar :alt="customerDisplayName(c)" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
<p class="font-medium text-sm text-[var(--text-primary)] truncate">{{ customerDisplayName(c) }}</p>
<UBadge
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
variant="soft" size="xs" class="flex-shrink-0"
@@ -332,7 +332,7 @@ const isCarFormValid = computed(() => {
<!-- Policy Type -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700">Policy Type</p>
<p class="font-semibold text-[var(--text-primary)]">Policy Type</p>
</template>
<div class="flex gap-4">
<div
@@ -344,7 +344,7 @@ const isCarFormValid = computed(() => {
? 'border-gray-100 bg-gray-50 opacity-40 cursor-not-allowed'
: policyType === item.value
? 'border-primary-500 bg-primary-50 cursor-pointer'
: 'border-gray-200 bg-white hover:border-gray-300 cursor-pointer'
: 'border-gray-200 bg-[var(--surface)] hover:border-gray-300 cursor-pointer'
]"
@click="!item.disabled && (policyType = item.value as any)"
>
@@ -365,7 +365,7 @@ const isCarFormValid = computed(() => {
<template v-if="policyType === 'car'">
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-truck" class="w-4 h-4" />
Vehicle Details
</p>
@@ -410,7 +410,7 @@ const isCarFormValid = computed(() => {
<!-- Provider Selection -->
<UCard>
<template #header>
<p class="font-semibold text-slate-700 flex items-center gap-2">
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
Selected Providers
<UBadge color="gray" variant="soft" size="xs">
@@ -438,14 +438,14 @@ const isCarFormValid = computed(() => {
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
:class="isProviderSelected(p)
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 bg-white'"
: 'border-gray-200 hover:border-gray-300 bg-[var(--surface)]'"
@click="toggleProvider(p)"
>
<div class="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0">
<UIcon name="i-heroicons-building-office" class="w-4 h-4 text-gray-500" />
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-sm text-slate-800 truncate">{{ p.name }}</p>
<p class="font-medium text-sm text-[var(--text-primary)] truncate">{{ p.name }}</p>
<p class="text-xs text-gray-400 truncate">{{ p.email }}</p>
</div>
<UIcon