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

View File

@@ -0,0 +1,499 @@
<script setup lang="ts">
import {
MOCK_RENEWALS, carrierStatusLabels, brokerStatusLabels, priorityLabels,
retentionRiskLabels, expiryBuckets, slaColor,
type Renewal, type CarrierRenewalStatus, type BrokerRenewalStatus,
type RenewalPriority, type RetentionRisk,
} from '~/data/mock-renewals'
usePageTitle('Renewals')
const renewals = ref<Renewal[]>([...MOCK_RENEWALS])
// ── View toggle ─────────────────────────────────────────────────────────────
const viewMode = ref<'my' | 'all'>('all')
// ── Filter tabs ─────────────────────────────────────────────────────────────
type PipelineFilter = 'all' | 'needs_action' | 'in_progress' | 'resolved'
const activeFilter = ref<PipelineFilter>('all')
// ── Dropdown filters ────────────────────────────────────────────────────────
const carrierFilter = ref('')
const lobFilter = ref('')
const handlerFilter = ref('')
const expiryFilter = ref('')
const priorityFilter = ref('')
const riskFilter = ref('')
const brokerStatusFilter = ref('')
const uniqueCarriers = computed(() => [...new Set(renewals.value.map(r => r.carrier))].sort())
const uniqueLobs = computed(() => [...new Set(renewals.value.map(r => r.lob))].sort())
const uniqueHandlers = computed(() => [...new Set(renewals.value.map(r => r.assignedTo))].sort())
// ── Filtering logic ─────────────────────────────────────────────────────────
const closedStatuses: BrokerRenewalStatus[] = ['closed_renewed', 'closed_remarketed', 'closed_cancelled', 'not_renewing']
const actionStatuses: BrokerRenewalStatus[] = ['unreviewed', 'under_review']
const progressStatuses: BrokerRenewalStatus[] = ['proposal_sent', 'awaiting_client_response', 'awaiting_payment']
const filteredRenewals = computed(() => {
let result = [...renewals.value]
// Tab filter
if (activeFilter.value === 'needs_action') result = result.filter(r => actionStatuses.includes(r.brokerStatus))
if (activeFilter.value === 'in_progress') result = result.filter(r => progressStatuses.includes(r.brokerStatus))
if (activeFilter.value === 'resolved') result = result.filter(r => closedStatuses.includes(r.brokerStatus))
// Dropdown filters
if (carrierFilter.value) result = result.filter(r => r.carrier === carrierFilter.value)
if (lobFilter.value) result = result.filter(r => r.lob === lobFilter.value)
if (handlerFilter.value) result = result.filter(r => r.assignedTo === handlerFilter.value)
if (priorityFilter.value) result = result.filter(r => r.priority === priorityFilter.value)
if (riskFilter.value) result = result.filter(r => r.retentionRisk === riskFilter.value)
if (brokerStatusFilter.value) result = result.filter(r => r.brokerStatus === brokerStatusFilter.value)
if (expiryFilter.value) {
const bucket = expiryBuckets[expiryFilter.value as keyof typeof expiryBuckets]
if (bucket) result = result.filter(r => r.daysUntilExpiry >= bucket.min && r.daysUntilExpiry <= bucket.max)
}
// Sort: breached first, then by days until expiry ascending
result.sort((a, b) => {
const aBreached = a.slaPercent >= 100 ? 0 : 1
const bBreached = b.slaPercent >= 100 ? 0 : 1
if (aBreached !== bBreached) return aBreached - bBreached
return a.daysUntilExpiry - b.daysUntilExpiry
})
return result
})
// ── Filter counts ───────────────────────────────────────────────────────────
const filterCounts = computed(() => ({
all: renewals.value.length,
needs_action: renewals.value.filter(r => actionStatuses.includes(r.brokerStatus)).length,
in_progress: renewals.value.filter(r => progressStatuses.includes(r.brokerStatus)).length,
resolved: renewals.value.filter(r => closedStatuses.includes(r.brokerStatus)).length,
}))
// ── KPIs ────────────────────────────────────────────────────────────────────
const kpis = computed(() => {
const active = renewals.value.filter(r => !closedStatuses.includes(r.brokerStatus))
const urgent = active.filter(r => r.daysUntilExpiry <= 7).length
const pipeline = active.reduce((s, r) => s + r.currentPremium, 0)
const atRisk = active.filter(r => r.retentionRisk === 'high').length
const breached = active.filter(r => r.slaPercent >= 100).length
const renewed = renewals.value.filter(r => r.brokerStatus === 'closed_renewed' || r.brokerStatus === 'closed_remarketed').length
return { urgent, pipeline, atRisk, breached, renewed }
})
// ── Helpers ─────────────────────────────────────────────────────────────────
function formatCurrency(n: number) {
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0 })
}
function daysDisplay(d: number) {
if (d < 0) return `${Math.abs(d)}d past`
if (d === 0) return 'Today'
return `${d}d`
}
function daysClass(d: number) {
if (d < 0) return 'rn-days-past'
if (d <= 7) return 'rn-days-critical'
if (d <= 30) return 'rn-days-warn'
return ''
}
const carrierPillClass = (s: CarrierRenewalStatus) => {
const map: Record<string, string> = {
pending: 'rn-csp-pending', terms_received: 'rn-csp-terms', remarketing: 'rn-csp-remarket',
bound: 'rn-csp-bound', declined: 'rn-csp-declined', lapsed: 'rn-csp-lapsed',
}
return map[s] ?? ''
}
const priorityClass = (p: RenewalPriority) => {
const map: Record<string, string> = { critical: 'rn-pri-critical', high: 'rn-pri-high', medium: 'rn-pri-medium', low: 'rn-pri-low' }
return map[p] ?? ''
}
const riskClass = (r: RetentionRisk) => {
const map: Record<string, string> = { high: 'rn-risk-high', medium: 'rn-risk-medium', low: 'rn-risk-low' }
return map[r] ?? ''
}
function clearFilters() {
carrierFilter.value = ''
lobFilter.value = ''
handlerFilter.value = ''
expiryFilter.value = ''
priorityFilter.value = ''
riskFilter.value = ''
brokerStatusFilter.value = ''
}
const hasActiveFilters = computed(() => !!(carrierFilter.value || lobFilter.value || handlerFilter.value || expiryFilter.value || priorityFilter.value || riskFilter.value || brokerStatusFilter.value))
const toast = useToast()
function handleSendReminders() {
toast.add({ title: 'Sending renewal reminders…', description: 'Batch reminder emails queued for processing.', color: 'green' })
}
</script>
<template>
<div class="rn-page">
<!-- Header -->
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="max-w-xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Renewals</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Pipeline de renovaciones seguimiento desde revisión hasta confirmación.
</p>
</div>
<div class="flex gap-2">
<NuxtLink to="/claims/settings" class="rn-action-btn-outline">
<UIcon name="i-heroicons-cog-6-tooth" style="width: 14px; height: 14px;" />
Settings
</NuxtLink>
<button type="button" class="rn-action-btn-primary" @click="handleSendReminders">
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
Send Reminders
</button>
</div>
</div>
<!-- KPI strip -->
<div class="rn-kpi-strip">
<div class="rn-kpi">
<p class="rn-kpi-label">Urgent ( 7d)</p>
<p class="rn-kpi-value" :style="kpis.urgent > 0 ? 'color: #c13838;' : ''">{{ kpis.urgent }}</p>
</div>
<div class="rn-kpi">
<p class="rn-kpi-label">Pipeline premium</p>
<p class="rn-kpi-value">{{ formatCurrency(kpis.pipeline) }}</p>
</div>
<div class="rn-kpi">
<p class="rn-kpi-label">High retention risk</p>
<p class="rn-kpi-value" :style="kpis.atRisk > 0 ? 'color: #c27b1a;' : ''">{{ kpis.atRisk }}</p>
</div>
<div class="rn-kpi">
<p class="rn-kpi-label">SLA breached</p>
<p class="rn-kpi-value" :style="kpis.breached > 0 ? 'color: #c13838;' : ''">{{ kpis.breached }}</p>
</div>
<div class="rn-kpi">
<p class="rn-kpi-label">Renewed MTD</p>
<p class="rn-kpi-value" style="color: #01696f;">{{ kpis.renewed }}</p>
</div>
</div>
<!-- View toggle + Filter tabs -->
<div class="rn-controls-row">
<div class="rn-view-toggle">
<button type="button" class="rn-view-btn" :class="viewMode === 'my' ? 'rn-view-on' : 'rn-view-off'" @click="viewMode = 'my'">My Renewals</button>
<button type="button" class="rn-view-btn" :class="viewMode === 'all' ? 'rn-view-on' : 'rn-view-off'" @click="viewMode = 'all'">All Renewals</button>
</div>
<div class="rn-filter-tabs">
<button
v-for="f in ([
{ id: 'all', label: 'All' },
{ id: 'needs_action', label: 'Needs Action' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'resolved', label: 'Resolved' },
] as { id: PipelineFilter; label: string }[])"
:key="f.id"
type="button"
class="rn-filter-tab"
:class="activeFilter === f.id ? 'rn-filter-on' : 'rn-filter-off'"
@click="activeFilter = f.id"
>
{{ f.label }}
<span class="rn-filter-count" :class="activeFilter === f.id ? 'rn-filter-count-on' : ''">{{ filterCounts[f.id] }}</span>
</button>
</div>
<span class="text-[11px] text-[var(--text-muted)] ml-auto">{{ filteredRenewals.length }} results</span>
</div>
<!-- Filter dropdowns -->
<div class="rn-dropdown-row">
<select v-model="brokerStatusFilter" class="rn-dropdown">
<option value="">Workflow</option>
<option v-for="(label, key) in brokerStatusLabels" :key="key" :value="key">{{ label }}</option>
</select>
<select v-model="carrierFilter" class="rn-dropdown">
<option value="">Carrier</option>
<option v-for="c in uniqueCarriers" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="lobFilter" class="rn-dropdown">
<option value="">LOB</option>
<option v-for="l in uniqueLobs" :key="l" :value="l">{{ l }}</option>
</select>
<select v-model="handlerFilter" class="rn-dropdown">
<option value="">Handler</option>
<option v-for="h in uniqueHandlers" :key="h" :value="h">{{ h }}</option>
</select>
<select v-model="expiryFilter" class="rn-dropdown">
<option value="">Expiry</option>
<option v-for="(b, key) in expiryBuckets" :key="key" :value="key">{{ b.label }}</option>
</select>
<select v-model="priorityFilter" class="rn-dropdown">
<option value="">Priority</option>
<option v-for="(label, key) in priorityLabels" :key="key" :value="key">{{ label }}</option>
</select>
<select v-model="riskFilter" class="rn-dropdown">
<option value="">Risk</option>
<option v-for="(label, key) in retentionRiskLabels" :key="key" :value="key">{{ label }}</option>
</select>
<button v-if="hasActiveFilters" class="rn-clear-btn" @click="clearFilters">
<UIcon name="i-heroicons-x-mark" class="w-3 h-3" />
Clear
</button>
</div>
<!-- Table -->
<div class="rn-table-wrap">
<table class="rn-table">
<thead>
<tr>
<th style="width: 28px;"></th>
<th>Renewal</th>
<th>Customer</th>
<th>LOB / Carrier</th>
<th>Status</th>
<th class="text-right">Current</th>
<th class="text-right">Renewal</th>
<th class="text-right">Expiry</th>
<th>Risk</th>
<th>Priority</th>
<th>Handler</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in filteredRenewals"
:key="r.id"
class="rn-row"
:class="{ 'rn-breach-row': r.slaPercent >= 100 && !closedStatuses.includes(r.brokerStatus) }"
style="cursor: pointer;"
@click="navigateTo(`/renewals/${r.id}`)"
>
<td><span class="rn-sla-dot" :class="`rn-sla-${slaColor(r.slaPercent)}`" /></td>
<td>
<NuxtLink :to="`/renewals/${r.id}`" class="rn-id-link" @click.stop>{{ r.id }}</NuxtLink>
<p class="text-[10px] text-[var(--text-muted)] font-mono mt-0.5">{{ r.policyNumber }}</p>
</td>
<td>
<p class="text-[13px] font-medium text-[var(--text-primary)]">{{ r.customerName || 'Unnamed customer' }}</p>
<p class="text-[10px] text-[var(--text-muted)] uppercase">{{ r.customerType }}</p>
</td>
<td>
<p class="text-[13px] text-[var(--text-primary)]">{{ r.lob }}</p>
<p class="text-[11px] text-[var(--text-muted)]">{{ r.carrier }}</p>
</td>
<td>
<div class="rn-dual-status">
<span class="rn-carrier-pill" :class="carrierPillClass(r.carrierStatus)">{{ carrierStatusLabels[r.carrierStatus] }}</span>
<span class="rn-workflow-pill">{{ brokerStatusLabels[r.brokerStatus] }}</span>
</div>
</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(r.currentPremium) }}</td>
<td class="text-right text-[13px]">
<template v-if="r.renewalPremium !== null">
<span class="font-semibold text-[var(--text-primary)]">{{ formatCurrency(r.renewalPremium) }}</span>
<span
v-if="r.premiumDelta !== null"
class="rn-delta"
:class="r.premiumDelta > 0 ? 'rn-delta-up' : r.premiumDelta < 0 ? 'rn-delta-down' : ''"
>{{ r.premiumDelta > 0 ? '+' : '' }}{{ r.premiumDelta }}%</span>
</template>
<span v-else class="text-[var(--text-muted)] opacity-40">—</span>
</td>
<td class="text-right">
<span class="text-[13px] font-bold" :class="daysClass(r.daysUntilExpiry)">{{ daysDisplay(r.daysUntilExpiry) }}</span>
<p class="text-[10px] text-[var(--text-muted)]">{{ r.expiryDate }}</p>
</td>
<td>
<span class="rn-risk-pill" :class="riskClass(r.retentionRisk)">{{ retentionRiskLabels[r.retentionRisk] }}</span>
</td>
<td>
<span :class="priorityClass(r.priority)">{{ priorityLabels[r.priority] }}</span>
</td>
<td class="text-[13px] text-[var(--text-muted)]">
<span :class="r.assignedTo === 'Unassigned' ? 'rn-unassigned' : ''">{{ r.assignedTo }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.rn-page {
max-width: 76rem; margin: 0 auto;
display: flex; flex-direction: column; gap: 20px; padding-bottom: 3rem;
}
/* ── Action buttons ── */
.rn-action-btn-primary {
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;
}
.rn-action-btn-primary:hover { background: #015458; }
.rn-action-btn-outline {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px;
background: transparent; color: var(--text-secondary);
font-size: 13px; font-weight: 500;
border: 1px solid rgba(0,0,0,0.1);
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
text-decoration: none;
}
.rn-action-btn-outline:hover { border-color: #01696f; color: #01696f; }
/* ── KPI strip ── */
.rn-kpi-strip {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px;
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
background: rgba(0,0,0,0.06); box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
.rn-kpi { padding: 14px 18px; background: #fff; }
.rn-kpi:first-child { border-radius: 12px 0 0 12px; }
.rn-kpi:last-child { border-radius: 0 12px 12px 0; }
.rn-kpi-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; color: #8a8a86;
}
.rn-kpi-value {
margin-top: 4px; font-size: 22px; font-weight: 600;
color: var(--text-primary); font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) { .rn-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
/* ── Controls row ── */
.rn-controls-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* ── View toggle ── */
.rn-view-toggle { display: inline-flex; gap: 1px; padding: 2px; border-radius: 8px; background: rgba(0,0,0,0.04); }
.rn-view-btn { padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; border: none; cursor: pointer; transition: all 150ms ease; white-space: nowrap; }
.rn-view-on { background: #01696f; color: white; }
.rn-view-off { background: transparent; color: #8a8a86; }
.rn-view-off:hover { color: var(--text-primary); }
/* ── Filter tabs ── */
.rn-filter-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,0.04); }
.rn-filter-tab {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.rn-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.rn-filter-off { background: transparent; color: var(--text-muted); }
.rn-filter-off:hover { color: var(--text-primary); }
.rn-filter-count { font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted); }
.rn-filter-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
/* ── Dropdown filters ── */
.rn-dropdown-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.rn-dropdown {
padding: 5px 10px; border-radius: 8px; font-size: 12px; font-weight: 500;
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
cursor: pointer; min-width: 100px;
}
.rn-dropdown:focus { outline: none; border-color: #01696f; }
.rn-clear-btn {
display: inline-flex; align-items: center; gap: 4px; padding: 5px 10px;
border-radius: 8px; font-size: 11px; font-weight: 600;
background: rgba(193,56,56,0.06); color: #c13838;
border: 1px solid rgba(193,56,56,0.15); cursor: pointer;
}
.rn-clear-btn:hover { background: rgba(193,56,56,0.12); }
/* ── Table ── */
.rn-table-wrap {
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-x: auto;
}
.rn-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rn-table thead 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);
white-space: nowrap; text-align: left;
}
.rn-table tbody td {
padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,0.04);
vertical-align: top;
}
.rn-row { transition: background 100ms ease; }
.rn-row:hover { background: rgba(0,0,0,0.015); }
.rn-row:last-child td { border-bottom: none; }
/* ── Breach row ── */
.rn-breach-row { box-shadow: inset 3px 0 0 #c13838; }
/* ── SLA dot ── */
.rn-sla-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.rn-sla-green { background: #059669; }
.rn-sla-amber { background: #c27b1a; }
.rn-sla-red { background: #c13838; }
/* ── ID link ── */
.rn-id-link {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px; font-weight: 600; color: #01696f;
text-decoration: none;
}
.rn-id-link:hover { text-decoration: underline; }
/* ── Dual status pills ── */
.rn-dual-status { display: flex; flex-direction: column; gap: 3px; }
.rn-carrier-pill {
display: inline-flex; padding: 2px 7px; border-radius: 8px;
font-size: 10px; font-weight: 600; white-space: nowrap;
}
.rn-csp-pending { background: rgba(138,138,134,0.08); color: #8a8a86; }
.rn-csp-terms { background: rgba(59,130,246,0.08); color: #2563eb; }
.rn-csp-remarket { background: rgba(147,51,234,0.08); color: #9333ea; }
.rn-csp-bound { background: rgba(22,163,74,0.08); color: #16a34a; }
.rn-csp-declined { background: rgba(193,56,56,0.08); color: #c13838; }
.rn-csp-lapsed { background: rgba(0,0,0,0.06); color: #6b6b68; }
.rn-workflow-pill {
display: inline-flex; padding: 0; border-radius: 0;
font-size: 10px; font-weight: 500; white-space: nowrap;
border: none; color: var(--text-muted);
}
/* ── Premium delta ── */
.rn-delta { font-size: 10px; font-weight: 700; margin-left: 4px; }
.rn-delta-up { color: #c13838; }
.rn-delta-down { color: #16a34a; }
/* ── Days display ── */
.rn-days-past { color: #8a8a86; opacity: 0.6; }
.rn-days-critical { color: #c13838; }
.rn-days-warn { color: #c27b1a; }
/* ── Risk pills ── */
.rn-risk-pill { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; white-space: nowrap; }
.rn-risk-high { background: rgba(193,56,56,0.08); color: #c13838; }
.rn-risk-medium { background: rgba(194,123,26,0.08); color: #c27b1a; }
.rn-risk-low { background: rgba(22,163,74,0.08); color: #16a34a; }
/* ── Priority badges ── */
.rn-pri-critical { font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 9999px; background: rgba(193,56,56,0.12); color: #c13838; white-space: nowrap; }
.rn-pri-high { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap; }
.rn-pri-medium { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.05); color: #6b6b68; white-space: nowrap; }
.rn-pri-low { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.03); color: #8a8a86; white-space: nowrap; }
/* ── Unassigned ── */
.rn-unassigned { font-style: italic; color: #c27b1a; }
</style>