WIP jordan
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "policy-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3737
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.claude/start-dev.sh
Executable file
4
.claude/start-dev.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
export PATH="/nix/store/cx0jd5mwhn7ihryyv0qp9d33f8s1iwb7-nodejs-24.13.0/bin:$PATH"
|
||||
cd /Users/jordanweingarten/Dev/policy-ui
|
||||
exec npm run dev
|
||||
167
app.config.ts
Normal file
167
app.config.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'primary',
|
||||
neutral: 'stone'
|
||||
}
|
||||
},
|
||||
|
||||
welcomeDashboard: {
|
||||
greetingName: 'User',
|
||||
productName: 'Segur-OS Beta',
|
||||
subtitle:
|
||||
'Orientation for today — pipeline health, receivables, and what needs attention before close of business.',
|
||||
|
||||
dailyTasks: [
|
||||
{ id: 't1', title: 'Review carrier responses in General Support queue', emphasis: true },
|
||||
{ id: 't2', title: 'Follow up on 3 quotes pending bind' },
|
||||
{ id: 't3', title: 'Approve 2 manual policy uploads from onboarding' },
|
||||
{ id: 't4', title: 'Check renewal diary for policies expiring in 60 days' }
|
||||
],
|
||||
|
||||
alerts: [
|
||||
{
|
||||
id: 'a1',
|
||||
message: '2 policies in renewal window need repricing approval.',
|
||||
tone: 'warning'
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
message: 'Carrier X API credential expires in 14 days — rotate in Settings.',
|
||||
tone: 'info'
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
message: 'Collections: 4 accounts over 60 days past due.',
|
||||
tone: 'error'
|
||||
}
|
||||
],
|
||||
|
||||
performanceKpis: [
|
||||
{
|
||||
id: 'ms',
|
||||
label: 'New business (MTD)',
|
||||
value: '42 policies',
|
||||
hint: 'Bound this month',
|
||||
change: '+6 vs prior month',
|
||||
changeTone: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'mr',
|
||||
label: 'Premium written (MTD)',
|
||||
value: '$1.24M',
|
||||
hint: 'GWP recognized',
|
||||
change: '+4.2% vs prior month',
|
||||
changeTone: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'ren',
|
||||
label: 'Renewals in flight',
|
||||
value: '128',
|
||||
hint: 'Quoted, not yet bound',
|
||||
change: '18 due ≤30d',
|
||||
changeTone: 'neutral'
|
||||
},
|
||||
{
|
||||
id: 'late',
|
||||
label: 'AR >30 days',
|
||||
value: '$186K',
|
||||
hint: 'Outstanding receivables',
|
||||
change: '−$12K vs last week',
|
||||
changeTone: 'positive'
|
||||
}
|
||||
],
|
||||
|
||||
ceoKpis: [
|
||||
{
|
||||
id: 'gwp',
|
||||
label: 'GWP (YTD)',
|
||||
value: '$14.8M',
|
||||
hint: 'Gross written premium',
|
||||
change: 'On plan',
|
||||
changeTone: 'neutral'
|
||||
},
|
||||
{
|
||||
id: 'if',
|
||||
label: 'Policies in force',
|
||||
value: '3,842',
|
||||
hint: 'Active policies',
|
||||
change: '+2.1% YoY',
|
||||
changeTone: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'lr',
|
||||
label: 'Loss ratio',
|
||||
value: '58.4%',
|
||||
hint: 'Trailing 12 months',
|
||||
change: 'Within appetite',
|
||||
changeTone: 'neutral'
|
||||
},
|
||||
{
|
||||
id: 'pipe',
|
||||
label: 'Quoted pipeline',
|
||||
value: '$2.06M',
|
||||
hint: 'Not yet bound',
|
||||
change: 'Weighted ~$1.1M',
|
||||
changeTone: 'neutral'
|
||||
}
|
||||
],
|
||||
|
||||
quickLinks: [
|
||||
{
|
||||
label: 'Quotes',
|
||||
to: '/quotes',
|
||||
icon: 'i-heroicons-chart-bar-square',
|
||||
description: 'Auto, health, life, general risk & custom — comparative and single-quote entry points.'
|
||||
},
|
||||
{
|
||||
label: 'Sales',
|
||||
to: '/onboarding',
|
||||
icon: 'i-heroicons-clipboard-document-list',
|
||||
description: 'Pipeline — leads, quotes, solicitudes, emissions, uploads.'
|
||||
},
|
||||
{
|
||||
label: 'Customers',
|
||||
to: '/customers',
|
||||
icon: 'i-heroicons-users',
|
||||
description: 'CRM — relationships and household / corporate profiles.'
|
||||
},
|
||||
{
|
||||
label: 'Policies',
|
||||
to: '/policies',
|
||||
icon: 'i-heroicons-rectangle-stack',
|
||||
description: 'Cartera — applications, quotes received, and bound policies.'
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
to: '/settings',
|
||||
icon: 'i-heroicons-cog-6-tooth',
|
||||
description: 'Branding, providers & carriers, permissions, forms catalog, and workspace setup.'
|
||||
},
|
||||
{
|
||||
label: 'Support inbox',
|
||||
to: '/support',
|
||||
icon: 'i-heroicons-chat-bubble-left-right',
|
||||
description: 'Carrier inbox — quotes, solicitations, and follow-ups.'
|
||||
},
|
||||
{
|
||||
label: 'Claims',
|
||||
to: '/claims',
|
||||
icon: 'i-heroicons-exclamation-triangle',
|
||||
description: 'Losses and filings — tie to policies and carriers.'
|
||||
},
|
||||
{
|
||||
label: 'Collections',
|
||||
to: '/collections',
|
||||
icon: 'i-heroicons-banknotes',
|
||||
description: 'Receivables and recovery workflows.'
|
||||
},
|
||||
{
|
||||
label: 'Renewals',
|
||||
to: '/renewals',
|
||||
icon: 'i-heroicons-arrow-path',
|
||||
description: 'Expiry diary and retention campaigns.'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1,2 +1,556 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
/* ── Nuxt UI primary color — teal ───────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-primary-50: #eefbfb;
|
||||
--color-primary-100: #d4f3f4;
|
||||
--color-primary-200: #a8e7e9;
|
||||
--color-primary-300: #6dd5d8;
|
||||
--color-primary-400: #33bdc2;
|
||||
--color-primary-500: #019fa6;
|
||||
--color-primary-600: #018388;
|
||||
--color-primary-700: #01696f;
|
||||
--color-primary-800: #015358;
|
||||
--color-primary-900: #014044;
|
||||
--color-primary-950: #002a2d;
|
||||
/* Hijack green so Nuxt UI var(--color-green-*, fallback) resolves here */
|
||||
--color-green-50: #eefbfb;
|
||||
--color-green-100: #d4f3f4;
|
||||
--color-green-200: #a8e7e9;
|
||||
--color-green-300: #6dd5d8;
|
||||
--color-green-400: #33bdc2;
|
||||
--color-green-500: #019fa6;
|
||||
--color-green-600: #018388;
|
||||
--color-green-700: #01696f;
|
||||
--color-green-800: #015358;
|
||||
--color-green-900: #014044;
|
||||
--color-green-950: #002a2d;
|
||||
}
|
||||
:root, :host {
|
||||
--ui-color-primary-50: #eefbfb !important;
|
||||
--ui-color-primary-100: #d4f3f4 !important;
|
||||
--ui-color-primary-200: #a8e7e9 !important;
|
||||
--ui-color-primary-300: #6dd5d8 !important;
|
||||
--ui-color-primary-400: #33bdc2 !important;
|
||||
--ui-color-primary-500: #019fa6 !important;
|
||||
--ui-color-primary-600: #018388 !important;
|
||||
--ui-color-primary-700: #01696f !important;
|
||||
--ui-color-primary-800: #015358 !important;
|
||||
--ui-color-primary-900: #014044 !important;
|
||||
--ui-color-primary-950: #002a2d !important;
|
||||
--ui-color-neutral-50: #f8f7f4 !important;
|
||||
--ui-color-neutral-100: #f5f3ef !important;
|
||||
--ui-color-neutral-200: #e8e6e2 !important;
|
||||
--ui-color-neutral-300: #d0d0cc !important;
|
||||
--ui-color-neutral-400: #a0a09c !important;
|
||||
--ui-color-neutral-500: #8a8a86 !important;
|
||||
--ui-color-neutral-600: #6b6b68 !important;
|
||||
--ui-color-neutral-700: #4a4a48 !important;
|
||||
--ui-color-neutral-800: #2e2e2c !important;
|
||||
--ui-color-neutral-900: #1a1a18 !important;
|
||||
--ui-color-neutral-950: #0f0f0e !important;
|
||||
}
|
||||
|
||||
/* ── Override Tailwind/Nuxt UI default ring color ────────────────────────── */
|
||||
:root {
|
||||
--tw-ring-color: var(--brand, #0d5c63) !important;
|
||||
}
|
||||
|
||||
/* ── Design tokens (normalized app chrome) ───────────────────────────────── */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--page-bg: #f8f7f4;
|
||||
--surface: #ffffff;
|
||||
--surface-elevated: #ffffff;
|
||||
--sidebar-bg: #f5f3ef;
|
||||
--sidebar-border: rgba(0, 0, 0, 0.04);
|
||||
--topbar-bg: rgba(248, 247, 244, 0.94);
|
||||
--text-primary: #1a1a18;
|
||||
--text-secondary: #6b6b68;
|
||||
--text-muted: #a0a09c;
|
||||
--text-faint: #c0c0bc;
|
||||
--brand: #01696f;
|
||||
--brand-hover: #015358;
|
||||
--brand-soft: rgba(1, 105, 111, 0.08);
|
||||
--brand-faint: rgba(1, 105, 111, 0.04);
|
||||
--nav-active-bg: rgba(1, 105, 111, 0.08);
|
||||
--nav-active-fg: #01696f;
|
||||
--nav-hover-bg: rgba(0, 0, 0, 0.05);
|
||||
--logo-chrome-bg: linear-gradient(135deg, rgba(1, 105, 111, 0.05) 0%, rgba(255, 255, 255, 0) 55%);
|
||||
--logo-blend: multiply;
|
||||
--accent-ridge: rgba(1, 105, 111, 0.25);
|
||||
|
||||
/* ── Extended tokens ── */
|
||||
--input-bg: #ffffff;
|
||||
--input-border: rgba(0, 0, 0, 0.08);
|
||||
--input-focus-ring: rgba(1, 105, 111, 0.20);
|
||||
--input-placeholder: #a0a09c;
|
||||
--card-border: rgba(0, 0, 0, 0.06);
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--badge-muted-bg: rgba(0, 0, 0, 0.05);
|
||||
--badge-muted-fg: #6b6b68;
|
||||
--divider: rgba(0, 0, 0, 0.06);
|
||||
--focus-ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--brand);
|
||||
--btn-neutral-bg: #f5f3ef;
|
||||
--btn-neutral-border: rgba(0, 0, 0, 0.12);
|
||||
--btn-neutral-hover: #eeedea;
|
||||
--success: #01696f;
|
||||
--success-soft: rgba(1, 105, 111, 0.08);
|
||||
--warning: #964219;
|
||||
--warning-soft: rgba(150, 66, 25, 0.08);
|
||||
--error: #c13838;
|
||||
--error-soft: rgba(193, 56, 56, 0.08);
|
||||
--info: #01696f;
|
||||
--info-soft: rgba(1, 105, 111, 0.06);
|
||||
--urgent: #dc2626;
|
||||
--urgent-soft: rgba(220, 38, 38, 0.06);
|
||||
--pending: #d97706;
|
||||
--pending-soft: rgba(217, 119, 6, 0.06);
|
||||
--skeleton: rgba(0, 0, 0, 0.04);
|
||||
--scrollbar-thumb: rgba(0, 0, 0, 0.12);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
[data-theme="purple"] {
|
||||
color-scheme: light;
|
||||
--page-bg: #f3f1ee;
|
||||
--surface: #f9f8f6;
|
||||
--surface-elevated: #ffffff;
|
||||
--sidebar-bg: #f5f3f0;
|
||||
--sidebar-border: rgba(100, 90, 80, 0.10);
|
||||
--topbar-bg: rgba(245, 243, 240, 0.92);
|
||||
--text-primary: #1a1a1a;
|
||||
--text-muted: #6b5f55;
|
||||
--brand: #5b3a8c;
|
||||
--brand-hover: #4a2d75;
|
||||
--brand-soft: rgba(91, 58, 140, 0.10);
|
||||
--brand-faint: rgba(91, 58, 140, 0.05);
|
||||
--nav-active-bg: rgba(91, 58, 140, 0.07);
|
||||
--nav-active-fg: #5b3a8c;
|
||||
--nav-hover-bg: rgba(100, 90, 80, 0.04);
|
||||
--logo-chrome-bg: linear-gradient(135deg, rgba(91, 58, 140, 0.06) 0%, rgba(255, 255, 255, 0) 55%);
|
||||
--logo-blend: multiply;
|
||||
--accent-ridge: rgba(91, 58, 140, 0.28);
|
||||
|
||||
--input-bg: #ffffff;
|
||||
--input-border: rgba(100, 90, 80, 0.16);
|
||||
--input-focus-ring: rgba(91, 58, 140, 0.25);
|
||||
--input-placeholder: #8c857d;
|
||||
--card-border: rgba(100, 90, 80, 0.10);
|
||||
--card-shadow: 0 1px 2px rgba(100, 90, 80, 0.06);
|
||||
--card-shadow-hover: 0 4px 12px rgba(100, 90, 80, 0.10), 0 1px 3px rgba(100, 90, 80, 0.06);
|
||||
--badge-muted-bg: rgba(100, 90, 80, 0.08);
|
||||
--badge-muted-fg: #6b5f55;
|
||||
--divider: rgba(100, 90, 80, 0.08);
|
||||
--focus-ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--brand);
|
||||
--btn-neutral-bg: #f5f3f0;
|
||||
--btn-neutral-border: rgba(100, 90, 80, 0.14);
|
||||
--btn-neutral-hover: #edeae6;
|
||||
--success: #0f7b5f;
|
||||
--success-soft: rgba(15, 123, 95, 0.10);
|
||||
--warning: #c27b1a;
|
||||
--warning-soft: rgba(194, 123, 26, 0.10);
|
||||
--error: #c13838;
|
||||
--error-soft: rgba(193, 56, 56, 0.10);
|
||||
--info: #5b3a8c;
|
||||
--info-soft: rgba(91, 58, 140, 0.08);
|
||||
--urgent: #dc2626;
|
||||
--urgent-soft: rgba(220, 38, 38, 0.06);
|
||||
--pending: #c27b1a;
|
||||
--pending-soft: rgba(194, 123, 26, 0.06);
|
||||
--skeleton: rgba(100, 90, 80, 0.06);
|
||||
--scrollbar-thumb: rgba(100, 90, 80, 0.18);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--page-bg: #111110;
|
||||
--surface: #1a1918;
|
||||
--surface-elevated: #222120;
|
||||
--sidebar-bg: #161514;
|
||||
--sidebar-border: rgba(168, 162, 155, 0.10);
|
||||
--topbar-bg: rgba(22, 21, 20, 0.94);
|
||||
--text-primary: #e8e4df;
|
||||
--text-muted: #8c857d;
|
||||
--brand: #2dd4bf;
|
||||
--brand-hover: #5eead4;
|
||||
--brand-soft: rgba(45, 212, 191, 0.12);
|
||||
--brand-faint: rgba(45, 212, 191, 0.06);
|
||||
--nav-active-bg: rgba(45, 212, 191, 0.10);
|
||||
--nav-active-fg: #5eead4;
|
||||
--nav-hover-bg: rgba(168, 162, 155, 0.06);
|
||||
--logo-chrome-bg: linear-gradient(135deg, rgba(45, 212, 191, 0.08) 0%, rgba(255, 255, 255, 0) 50%);
|
||||
--logo-blend: screen;
|
||||
--accent-ridge: rgba(45, 212, 191, 0.35);
|
||||
|
||||
--input-bg: #222120;
|
||||
--input-border: rgba(168, 162, 155, 0.14);
|
||||
--input-focus-ring: rgba(45, 212, 191, 0.30);
|
||||
--input-placeholder: #6b6560;
|
||||
--card-border: rgba(168, 162, 155, 0.08);
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.30);
|
||||
--card-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.20);
|
||||
--badge-muted-bg: rgba(168, 162, 155, 0.10);
|
||||
--badge-muted-fg: #8c857d;
|
||||
--divider: rgba(168, 162, 155, 0.06);
|
||||
--focus-ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--brand);
|
||||
--btn-neutral-bg: #222120;
|
||||
--btn-neutral-border: rgba(168, 162, 155, 0.12);
|
||||
--btn-neutral-hover: #2a2928;
|
||||
--success: #2dd4a8;
|
||||
--success-soft: rgba(45, 212, 168, 0.12);
|
||||
--warning: #f0b429;
|
||||
--warning-soft: rgba(240, 180, 41, 0.12);
|
||||
--error: #f87171;
|
||||
--error-soft: rgba(248, 113, 113, 0.12);
|
||||
--info: #2dd4bf;
|
||||
--info-soft: rgba(45, 212, 191, 0.08);
|
||||
--urgent: #f87171;
|
||||
--urgent-soft: rgba(248, 113, 113, 0.10);
|
||||
--pending: #f0b429;
|
||||
--pending-soft: rgba(240, 180, 41, 0.10);
|
||||
--skeleton: rgba(168, 162, 155, 0.06);
|
||||
--scrollbar-thumb: rgba(168, 162, 155, 0.18);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark-purple"] {
|
||||
color-scheme: dark;
|
||||
--page-bg: #13111a;
|
||||
--surface: #1c1926;
|
||||
--surface-elevated: #252230;
|
||||
--sidebar-bg: #17141e;
|
||||
--sidebar-border: rgba(160, 148, 180, 0.10);
|
||||
--topbar-bg: rgba(23, 20, 30, 0.94);
|
||||
--text-primary: #ede8f4;
|
||||
--text-muted: #918899;
|
||||
--brand: #b898e8;
|
||||
--brand-hover: #d0b8f8;
|
||||
--brand-soft: rgba(184, 152, 232, 0.12);
|
||||
--brand-faint: rgba(184, 152, 232, 0.06);
|
||||
--nav-active-bg: rgba(184, 152, 232, 0.10);
|
||||
--nav-active-fg: #d0b8f8;
|
||||
--nav-hover-bg: rgba(160, 148, 180, 0.06);
|
||||
--logo-chrome-bg: linear-gradient(135deg, rgba(184, 152, 232, 0.08) 0%, rgba(255, 255, 255, 0) 50%);
|
||||
--logo-blend: screen;
|
||||
--accent-ridge: rgba(184, 152, 232, 0.30);
|
||||
|
||||
--input-bg: #252230;
|
||||
--input-border: rgba(160, 148, 180, 0.14);
|
||||
--input-focus-ring: rgba(184, 152, 232, 0.30);
|
||||
--input-placeholder: #6b6075;
|
||||
--card-border: rgba(160, 148, 180, 0.08);
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.30);
|
||||
--card-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.20);
|
||||
--badge-muted-bg: rgba(160, 148, 180, 0.10);
|
||||
--badge-muted-fg: #918899;
|
||||
--divider: rgba(160, 148, 180, 0.06);
|
||||
--focus-ring: 0 0 0 2px var(--surface), 0 0 0 4px var(--brand);
|
||||
--btn-neutral-bg: #252230;
|
||||
--btn-neutral-border: rgba(160, 148, 180, 0.12);
|
||||
--btn-neutral-hover: #2e2a3a;
|
||||
--success: #34d399;
|
||||
--success-soft: rgba(52, 211, 153, 0.12);
|
||||
--warning: #f0b429;
|
||||
--warning-soft: rgba(240, 180, 41, 0.12);
|
||||
--error: #f87171;
|
||||
--error-soft: rgba(248, 113, 113, 0.12);
|
||||
--info: #b898e8;
|
||||
--info-soft: rgba(184, 152, 232, 0.08);
|
||||
--urgent: #f87171;
|
||||
--urgent-soft: rgba(248, 113, 113, 0.10);
|
||||
--pending: #f0b429;
|
||||
--pending-soft: rgba(240, 180, 41, 0.10);
|
||||
--skeleton: rgba(160, 148, 180, 0.06);
|
||||
--scrollbar-thumb: rgba(160, 148, 180, 0.18);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
/* ── Global reset & base ─────────────────────────────────────────────────── */
|
||||
html {
|
||||
background-color: var(--page-bg);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
/* Smooth theme transitions */
|
||||
body,
|
||||
.app-sidebar-link,
|
||||
aside,
|
||||
header,
|
||||
main,
|
||||
[class*="rounded-xl"] {
|
||||
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
/* ── Custom scrollbar ────────────────────────────────────────────────────── */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--brand-soft); }
|
||||
|
||||
/* ── Nuxt UI component overrides (theme-aware) ──────────────────────────── */
|
||||
|
||||
/* Buttons — global refinements for Nuxt UI buttons */
|
||||
[data-theme] button,
|
||||
[data-theme] [role="button"],
|
||||
[data-theme] a[class*="UButton"],
|
||||
[data-theme] .ui-button-solid-primary,
|
||||
[data-theme] button[class*="bg-[var(--brand)]"] {
|
||||
transition: all 150ms ease;
|
||||
border-radius: 0.5rem;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
/* Solid primary buttons — deeper shadow for depth */
|
||||
[data-theme] [class*="bg-primary"] {
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--brand) 20%, transparent);
|
||||
}
|
||||
[data-theme] [class*="bg-primary"]:hover {
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--brand) 28%, transparent);
|
||||
}
|
||||
|
||||
/* Inputs & selects */
|
||||
[data-theme] input:not([type="checkbox"]):not([type="radio"]),
|
||||
[data-theme] textarea,
|
||||
[data-theme] select,
|
||||
[data-theme] [role="combobox"] {
|
||||
background-color: var(--input-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
color: var(--text-primary) !important;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease !important;
|
||||
}
|
||||
[data-theme] input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
[data-theme] textarea:focus,
|
||||
[data-theme] select:focus,
|
||||
[data-theme] [role="combobox"]:focus {
|
||||
border-color: var(--brand) !important;
|
||||
box-shadow: 0 0 0 3px var(--input-focus-ring) !important;
|
||||
}
|
||||
[data-theme] input::placeholder,
|
||||
[data-theme] textarea::placeholder {
|
||||
color: var(--input-placeholder) !important;
|
||||
}
|
||||
|
||||
/* Cards — universal card treatment */
|
||||
.app-card {
|
||||
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);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.app-card:hover {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.app-card-interactive:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
/* Badge helpers */
|
||||
.app-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
/* Dividers */
|
||||
.app-divider {
|
||||
border-color: var(--divider);
|
||||
}
|
||||
|
||||
/* ── Logo: soften white box PNGs — blend into sidebar chrome ─────────────── */
|
||||
.app-logo-chrome {
|
||||
background: var(--logo-chrome-bg);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
isolation: isolate;
|
||||
}
|
||||
.app-logo-chrome img {
|
||||
mix-blend-mode: var(--logo-blend);
|
||||
max-height: 2rem;
|
||||
width: auto;
|
||||
max-width: 10rem;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
filter: contrast(1.05) saturate(1.02);
|
||||
}
|
||||
|
||||
/* ── Typography helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
.app-heading {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.app-body-muted {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tabular nums on dashboard container */
|
||||
.app-dashboard-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Ridge accent on chrome buttons (top bar) ────────────────────────────── */
|
||||
.app-chrome-btn {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.app-chrome-btn::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(135deg, var(--accent-ridge) 0%, transparent 55%);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Sidebar navigation ──────────────────────────────────────────────────── */
|
||||
.app-sidebar-link {
|
||||
border-radius: 8px;
|
||||
transition: background-color 150ms ease;
|
||||
outline: none !important;
|
||||
}
|
||||
.app-sidebar-link:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.app-sidebar-link:focus-visible {
|
||||
outline: 2px solid var(--brand) !important;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.app-sidebar-link-active {
|
||||
background-color: rgba(0, 0, 0, 0.045);
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Sidebar child link active — text only, no pill */
|
||||
.app-sidebar-child-active {
|
||||
color: #01696f !important;
|
||||
font-weight: 500;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Section header labels in sidebar */
|
||||
.app-sidebar-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #a0a09c;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── KPI card treatment ──────────────────────────────────────────────────── */
|
||||
.app-kpi-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: box-shadow 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.app-kpi-card:hover {
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
/* ── Quick-link tiles ────────────────────────────────────────────────────── */
|
||||
.app-quick-link {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.app-quick-link:hover {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
.app-quick-link:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Theme picker card ───────────────────────────────────────────────────── */
|
||||
.app-theme-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 150ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-theme-card:hover {
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.app-theme-card-selected {
|
||||
border-color: var(--brand) !important;
|
||||
box-shadow: 0 0 0 2px var(--brand), var(--card-shadow);
|
||||
}
|
||||
|
||||
/* ── Animated skeleton ───────────────────────────────────────────────────── */
|
||||
@keyframes app-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.app-skeleton {
|
||||
background: linear-gradient(90deg, var(--skeleton) 25%, transparent 50%, var(--skeleton) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: app-shimmer 1.8s ease-in-out infinite;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* ── Focus visible ring ──────────────────────────────────────────────────── */
|
||||
.app-focus-ring:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Kill all default blue focus rings — use brand color everywhere */
|
||||
*:focus {
|
||||
outline-color: var(--brand) !important;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline-color: var(--brand) !important;
|
||||
}
|
||||
button:focus,
|
||||
a:focus,
|
||||
[role="button"]:focus,
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--brand) 50%, transparent) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Sidebar buttons — suppress focus outline in favor of hover state */
|
||||
.app-sidebar-link:focus:not(:focus-visible) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* ── Entrance animations (disabled per spec — no entrance animations) ──── */
|
||||
.app-animate-in { /* no-op */ }
|
||||
.app-stagger > * { /* no-op */ }
|
||||
|
||||
7
app/components/AppBackToHome.vue
Normal file
7
app/components/AppBackToHome.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<NuxtLink to="/" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-home">
|
||||
Home
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
129
app/components/account/AccountThemeSection.vue
Normal file
129
app/components/account/AccountThemeSection.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
|
||||
const { themeId, themeOptions, applyTheme } = useAppTheme()
|
||||
|
||||
const themeGradients: Record<string, string> = {
|
||||
light: 'from-sky-100 via-blue-50 to-indigo-100',
|
||||
purple: 'from-violet-100 via-fuchsia-50 to-purple-100',
|
||||
dark: 'from-slate-700 via-slate-800 to-slate-900',
|
||||
'dark-purple': 'from-violet-900 via-purple-950 to-slate-900'
|
||||
}
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm leading-relaxed text-[var(--text-muted)]">
|
||||
Choose a theme for the entire application. Colors, inputs, buttons, cards, sidebar, and top bar all follow your choice.
|
||||
Your preference is saved to this browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Theme grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="opt in themeOptions"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="app-theme-card text-left"
|
||||
:class="themeId === opt.id ? 'app-theme-card-selected' : ''"
|
||||
@click="applyTheme(opt.id as AppThemeId)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
:class="themeId === opt.id ? 'bg-[var(--brand-soft)] text-[var(--brand)]' : 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]'"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ opt.label }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ opt.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-75"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-75"
|
||||
>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
name="i-heroicons-check-circle-solid"
|
||||
class="h-6 w-6 shrink-0 text-[var(--brand)]"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Gradient preview bar -->
|
||||
<div
|
||||
class="mt-4 h-8 overflow-hidden rounded-lg bg-gradient-to-r"
|
||||
:class="themeGradients[opt.id]"
|
||||
/>
|
||||
|
||||
<!-- Component preview (scoped to this theme) -->
|
||||
<div
|
||||
class="mt-3 rounded-lg border border-[var(--sidebar-border)] bg-[var(--page-bg)] p-3"
|
||||
:data-theme="opt.id"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Buttons row -->
|
||||
<div>
|
||||
<p class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Buttons</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UButton size="xs" color="primary">Primary</UButton>
|
||||
<UButton size="xs" color="primary" variant="soft">Soft</UButton>
|
||||
<UButton size="xs" color="primary" variant="outline">Outline</UButton>
|
||||
<UButton size="xs" color="neutral" variant="ghost">Ghost</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input preview -->
|
||||
<div>
|
||||
<p class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Input</p>
|
||||
<div class="max-w-[200px]">
|
||||
<UInput size="xs" placeholder="Search policies..." icon="i-heroicons-magnifying-glass" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Badges row -->
|
||||
<div>
|
||||
<p class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Badges</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<UBadge color="primary" variant="soft" size="xs">Active</UBadge>
|
||||
<UBadge color="success" variant="soft" size="xs">Bound</UBadge>
|
||||
<UBadge color="warning" variant="soft" size="xs">Pending</UBadge>
|
||||
<UBadge color="error" variant="soft" size="xs">Overdue</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Extra info -->
|
||||
<div class="rounded-xl border border-[var(--card-border)] bg-[var(--surface)] p-4 shadow-sm">
|
||||
<div class="flex gap-3">
|
||||
<UIcon name="i-heroicons-information-circle" class="mt-0.5 h-5 w-5 shrink-0 text-[var(--brand)]" />
|
||||
<div class="text-sm text-[var(--text-muted)]">
|
||||
<p>
|
||||
Theme applies instantly to all pages including sidebar navigation, cards, inputs, buttons, badges, and KPI panels.
|
||||
You can also quickly switch themes from the
|
||||
<UIcon name="i-heroicons-swatch" class="inline h-3.5 w-3.5" />
|
||||
icon in the top bar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
app/components/home/DashboardWidgetBlocks.vue
Normal file
55
app/components/home/DashboardWidgetBlocks.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { DashboardWidgetId } from '~/composables/useDashboardHomeWidgets'
|
||||
|
||||
const props = defineProps<{
|
||||
widgetOrder: DashboardWidgetId[]
|
||||
widgets: Record<DashboardWidgetId, boolean>
|
||||
layoutUnlocked: boolean
|
||||
draggingWidget: DashboardWidgetId | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dragStart: [wid: DashboardWidgetId, e: DragEvent]
|
||||
dragEnd: []
|
||||
drop: [wid: DashboardWidgetId, e: DragEvent]
|
||||
}>()
|
||||
|
||||
function shellClass(wid: DashboardWidgetId) {
|
||||
return [
|
||||
props.layoutUnlocked ? 'rounded-2xl ring-1 ring-dashed ring-[var(--brand-soft)]/50' : '',
|
||||
props.draggingWidget === wid ? 'opacity-[0.58]' : ''
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl space-y-10">
|
||||
<template v-for="wid in widgetOrder" :key="'dash-' + wid">
|
||||
<section
|
||||
v-show="widgets[wid]"
|
||||
:class="shellClass(wid)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="emit('drop', wid, $event)"
|
||||
>
|
||||
<div class="flex items-start gap-1 sm:gap-3">
|
||||
<div v-if="layoutUnlocked" class="flex shrink-0 flex-col pt-1">
|
||||
<button
|
||||
type="button"
|
||||
draggable="true"
|
||||
class="select-none cursor-grab rounded-lg border border-[var(--card-border)]/90 bg-[var(--surface)] p-2 text-[var(--text-muted)] shadow-sm hover:bg-[var(--badge-muted-bg)] active:cursor-grabbing"
|
||||
tabindex="-1"
|
||||
aria-label="Drag to reorder section"
|
||||
@dragstart.stop="emit('dragStart', wid, $event)"
|
||||
@dragend="emit('dragEnd')"
|
||||
>
|
||||
<UIcon name="i-heroicons-bars-3" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<slot :name="wid" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
439
app/components/layout/AppCommandSearch.vue
Normal file
439
app/components/layout/AppCommandSearch.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Global command palette — searches customers, policies, claims, and app pages.
|
||||
* Keyboard: Ctrl/Cmd+K to focus, Escape to close, ↑↓ to navigate, Enter to go.
|
||||
*/
|
||||
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
||||
|
||||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
const q = ref('')
|
||||
const inputRef = ref<HTMLElement | null>(null)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
/* ── Build searchable records from mock data ── */
|
||||
|
||||
type SearchHit = {
|
||||
id: string
|
||||
kind: 'customer' | 'policy' | 'claim' | 'page'
|
||||
icon: string
|
||||
title: string
|
||||
meta: string
|
||||
detail?: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const allRecords = computed<SearchHit[]>(() => {
|
||||
const hits: SearchHit[] = []
|
||||
|
||||
for (const c of MOCK_CUSTOMERS) {
|
||||
// Customer record
|
||||
hits.push({
|
||||
id: `cust-${c.id}`,
|
||||
kind: 'customer',
|
||||
icon: 'i-heroicons-user',
|
||||
title: c.name,
|
||||
meta: `${c.type} · ${c.documentId}`,
|
||||
detail: `${c.policies.length} policies · ${fmtMoney(c.policies.reduce((s, p) => s + p.premium, 0))}/yr · Agent: ${c.agent}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
|
||||
// Each policy
|
||||
for (const p of c.policies) {
|
||||
hits.push({
|
||||
id: `pol-${p.id}`,
|
||||
kind: 'policy',
|
||||
icon: p.icon,
|
||||
title: p.id,
|
||||
meta: `${p.line} · ${p.carrier} · ${c.name}`,
|
||||
detail: p.product,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// Each claim
|
||||
for (const cl of c.claims) {
|
||||
hits.push({
|
||||
id: `claim-${cl.id}`,
|
||||
kind: 'claim',
|
||||
icon: 'i-heroicons-shield-exclamation',
|
||||
title: cl.id,
|
||||
meta: `${cl.type} · ${cl.status} · ${c.name}`,
|
||||
detail: `Policy ${cl.policy} · $${cl.amount.toLocaleString()}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return hits
|
||||
})
|
||||
|
||||
/* ── App pages / destinations ── */
|
||||
const APP_PAGES: SearchHit[] = [
|
||||
{ id: 'p-home', kind: 'page', icon: 'i-heroicons-squares-2x2', title: 'My Dashboard', meta: 'Home', to: '/' },
|
||||
{ id: 'p-calendar', kind: 'page', icon: 'i-heroicons-calendar-days', title: 'Calendar', meta: 'Agenda & reminders', to: '/calendar' },
|
||||
|
||||
// Quotes
|
||||
{ id: 'p-quotes', kind: 'page', icon: 'i-heroicons-calculator', title: 'Quotes Overview', meta: 'Quotes', to: '/quotes' },
|
||||
{ id: 'p-quotes-auto', kind: 'page', icon: 'i-heroicons-truck', title: 'Auto Quotes', meta: 'Quotes · Motor & fleet', to: '/quotes/auto' },
|
||||
{ id: 'p-quotes-health', kind: 'page', icon: 'i-heroicons-heart', title: 'Health Quotes', meta: 'Quotes · Collective & individual', to: '/quotes/health' },
|
||||
{ id: 'p-quotes-life', kind: 'page', icon: 'i-heroicons-shield-check', title: 'Life Quotes', meta: 'Quotes · Individual & corporate', to: '/quotes/life' },
|
||||
{ id: 'p-quotes-risk', kind: 'page', icon: 'i-heroicons-building-office-2', title: 'General Risk', meta: 'Quotes · Liability & specialty', to: '/quotes/general-risk' },
|
||||
|
||||
// Sales
|
||||
{ id: 'p-ql', kind: 'page', icon: 'i-heroicons-bolt', title: 'Quick Lead', meta: 'Sales · Fast lead capture', to: '/sales/quick-lead' },
|
||||
{ id: 'p-pipeline', kind: 'page', icon: 'i-heroicons-funnel', title: 'Sales Pipeline', meta: 'Sales · Kanban board', to: '/onboarding' },
|
||||
{ id: 'p-solicitud', kind: 'page', icon: 'i-heroicons-document-text', title: 'New Solicitud', meta: 'Sales · Onboarding intake', to: '/onboarding/solicitud' },
|
||||
{ id: 'p-emissions', kind: 'page', icon: 'i-heroicons-paper-airplane', title: 'Emissions Review', meta: 'Sales · QA & carrier submit', to: '/onboarding/emissions' },
|
||||
{ id: 'p-nombra', kind: 'page', icon: 'i-heroicons-document-arrow-up', title: 'Nombramiento', meta: 'Sales · Broker-of-record transfer', to: '/onboarding/policy-upload/new' },
|
||||
|
||||
// Operations
|
||||
{ id: 'p-customers', kind: 'page', icon: 'i-heroicons-users', title: 'Customers', meta: 'Operations · CRM', to: '/customers' },
|
||||
{ id: 'p-new-customer', kind: 'page', icon: 'i-heroicons-user-plus', title: 'New Customer', meta: 'Operations · Registration', to: '/customers/new' },
|
||||
{ id: 'p-policies', kind: 'page', icon: 'i-heroicons-briefcase', title: 'Policies', meta: 'Operations · Book of business', to: '/policies' },
|
||||
|
||||
// Workstations
|
||||
{ id: 'p-collectivos', kind: 'page', icon: 'i-heroicons-user-group', title: 'Collectivos', meta: 'Workstations · Group management', to: '/workstation/collectivos' },
|
||||
{ id: 'p-collections', kind: 'page', icon: 'i-heroicons-banknotes', title: 'Collections', meta: 'Workstations', to: '/workstation/collections' },
|
||||
{ id: 'p-claims-ws', kind: 'page', icon: 'i-heroicons-shield-exclamation', title: 'Claims', meta: 'Workstations', to: '/workstation/claims' },
|
||||
{ id: 'p-renewals', kind: 'page', icon: 'i-heroicons-arrow-path', title: 'Renewals', meta: 'Workstations', to: '/workstation/renewals' },
|
||||
{ id: 'p-cs', kind: 'page', icon: 'i-heroicons-chat-bubble-left-right', title: 'Customer Service', meta: 'Workstations', to: '/workstation/customer-service' },
|
||||
{ id: 'p-sales-factory', kind: 'page', icon: 'i-heroicons-rocket-launch', title: 'Sales Factory', meta: 'AI Tools · Lead gen & cross-sell', to: '/ai-tools/sales-factory' },
|
||||
{ id: 'p-facturacion', kind: 'page', icon: 'i-heroicons-document-text', title: 'Facturación', meta: 'Workstations · Invoicing', to: '/workstation/facturacion' },
|
||||
|
||||
// AI Tools
|
||||
{ id: 'p-comparator', kind: 'page', icon: 'i-heroicons-scale', title: 'Policy Comparator', meta: 'AI Tools', to: '/ai-tools/policy-comparator' },
|
||||
{ id: 'p-email', kind: 'page', icon: 'i-heroicons-envelope', title: 'Email Writer', meta: 'AI Tools', to: '/ai-tools/email-writer' },
|
||||
{ id: 'p-case', kind: 'page', icon: 'i-heroicons-light-bulb', title: 'Case Assistant', meta: 'AI Tools', to: '/ai-tools/case-assistant' },
|
||||
|
||||
// Reports
|
||||
{ id: 'p-production', kind: 'page', icon: 'i-heroicons-chart-bar', title: 'Production Report', meta: 'Reports & Analysis', to: '/analysis/production' },
|
||||
{ id: 'p-commissions', kind: 'page', icon: 'i-heroicons-currency-dollar', title: 'Commissions', meta: 'Reports & Analysis', to: '/analysis/commissions' },
|
||||
{ id: 'p-claims-report', kind: 'page', icon: 'i-heroicons-chart-pie', title: 'Claims Report', meta: 'Reports & Analysis', to: '/analysis/claims' },
|
||||
|
||||
// Settings
|
||||
{ id: 'p-account', kind: 'page', icon: 'i-heroicons-user-circle', title: 'My Account', meta: 'Settings · Profile & theme', to: '/account' },
|
||||
{ id: 'p-settings', kind: 'page', icon: 'i-heroicons-cog-6-tooth', title: 'Software Settings', meta: 'Settings', to: '/settings' },
|
||||
{ id: 'p-agents', kind: 'page', icon: 'i-heroicons-user-group', title: 'Agents & Commissions', meta: 'Settings · Producer management', to: '/settings/agents' },
|
||||
{ id: 'p-org', kind: 'page', icon: 'i-heroicons-building-office', title: 'Organization', meta: 'Settings · Company & logo', to: '/settings/organization' },
|
||||
{ id: 'p-forms', kind: 'page', icon: 'i-heroicons-clipboard-document-list', title: 'Forms Library', meta: 'Settings · Insurer forms', to: '/settings/forms' },
|
||||
{ id: 'p-providers', kind: 'page', icon: 'i-heroicons-building-storefront', title: 'Providers', meta: 'Settings · Carrier setup', to: '/settings/providers' },
|
||||
{ id: 'p-permissions', kind: 'page', icon: 'i-heroicons-lock-closed', title: 'Permissions', meta: 'Settings · Roles & access', to: '/settings/permissions' },
|
||||
|
||||
// Tasks
|
||||
{ id: 'p-tasks', kind: 'page', icon: 'i-heroicons-clipboard-document-check', title: 'Tasks', meta: 'Work management', to: '/tasks' },
|
||||
]
|
||||
|
||||
/* ── Search filtering ── */
|
||||
const needle = computed(() => q.value.trim().toLowerCase())
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
if (!needle.value) return []
|
||||
const n = needle.value
|
||||
return allRecords.value.filter(
|
||||
(x) =>
|
||||
x.title.toLowerCase().includes(n) ||
|
||||
x.meta.toLowerCase().includes(n) ||
|
||||
(x.detail?.toLowerCase().includes(n) ?? false)
|
||||
).slice(0, 8)
|
||||
})
|
||||
|
||||
const filteredPages = computed(() => {
|
||||
if (!needle.value) return APP_PAGES.slice(0, 6) // show top pages when empty
|
||||
const n = needle.value
|
||||
return APP_PAGES.filter(
|
||||
(x) =>
|
||||
x.title.toLowerCase().includes(n) ||
|
||||
x.meta.toLowerCase().includes(n)
|
||||
).slice(0, 8)
|
||||
})
|
||||
|
||||
const allFiltered = computed(() => [...filteredRecords.value, ...filteredPages.value])
|
||||
|
||||
/* ── Keyboard navigation ── */
|
||||
function navigate(hit: SearchHit) {
|
||||
open.value = false
|
||||
q.value = ''
|
||||
activeIndex.value = -1
|
||||
router.push(hit.to)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) return
|
||||
|
||||
const total = allFiltered.value.length
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = activeIndex.value <= 0 ? total - 1 : activeIndex.value - 1
|
||||
} else if (e.key === 'Enter' && activeIndex.value >= 0 && activeIndex.value < total) {
|
||||
e.preventDefault()
|
||||
navigate(allFiltered.value[activeIndex.value]!)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
open.value = false
|
||||
activeIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global Cmd/Ctrl+K ── */
|
||||
function onGlobalKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
open.value = true
|
||||
nextTick(() => {
|
||||
const el = inputRef.value
|
||||
if (el) {
|
||||
const input = el.querySelector('input') ?? (el as HTMLInputElement)
|
||||
input?.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Click outside ── */
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el || !open.value) return
|
||||
if (!el.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
activeIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
document.addEventListener('keydown', onGlobalKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
document.removeEventListener('keydown', onGlobalKey)
|
||||
})
|
||||
|
||||
watch(q, () => {
|
||||
open.value = true
|
||||
activeIndex.value = -1
|
||||
})
|
||||
|
||||
/* ── Kind colors ── */
|
||||
const kindColor: Record<string, string> = {
|
||||
customer: '#01696f',
|
||||
policy: '#7c3aed',
|
||||
claim: '#c13838',
|
||||
page: '#8a8a86',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative w-full max-w-xl min-w-0 flex-1">
|
||||
<div ref="inputRef">
|
||||
<UInput
|
||||
v-model="q"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search customers, policies, pages… (⌘K)"
|
||||
class="w-full"
|
||||
@focus="open = true"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="open"
|
||||
class="cs-dropdown"
|
||||
>
|
||||
<div class="max-h-[min(70vh,460px)] overflow-y-auto">
|
||||
<!-- Records section (customers, policies, claims) -->
|
||||
<template v-if="filteredRecords.length > 0">
|
||||
<div class="cs-section-head">
|
||||
<span>Records</span>
|
||||
<span class="cs-section-count">{{ filteredRecords.length }}</span>
|
||||
</div>
|
||||
<ul class="py-1">
|
||||
<li v-for="(r, i) in filteredRecords" :key="r.id">
|
||||
<button
|
||||
type="button"
|
||||
class="cs-hit"
|
||||
:class="activeIndex === i ? 'cs-hit-active' : ''"
|
||||
@click="navigate(r)"
|
||||
@mouseenter="activeIndex = i"
|
||||
>
|
||||
<div class="cs-hit-icon" :style="`color: ${kindColor[r.kind] || '#8a8a86'}`">
|
||||
<UIcon :name="r.icon" style="width: 16px; height: 16px;" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate text-[13px] font-medium text-[var(--text-primary)]">{{ r.title }}</p>
|
||||
<span class="cs-kind-badge" :style="`background: ${kindColor[r.kind]}15; color: ${kindColor[r.kind]}`">
|
||||
{{ r.kind }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ r.meta }}</p>
|
||||
<p v-if="r.detail" class="truncate text-[11px] text-[var(--text-muted)] opacity-70">{{ r.detail }}</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-right" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- No record results message -->
|
||||
<template v-if="needle && filteredRecords.length === 0">
|
||||
<div class="cs-section-head">
|
||||
<span>Records</span>
|
||||
</div>
|
||||
<p class="px-4 py-3 text-[12px] text-[var(--text-muted)]">
|
||||
No customers, policies, or claims match "{{ q.trim() }}"
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Pages / Go to section -->
|
||||
<template v-if="filteredPages.length > 0">
|
||||
<div class="cs-section-head" :class="filteredRecords.length > 0 ? 'cs-section-border' : ''">
|
||||
<span>{{ needle ? 'Pages' : 'Quick access' }}</span>
|
||||
</div>
|
||||
<ul class="py-1 pb-2">
|
||||
<li v-for="(a, ai) in filteredPages" :key="a.id">
|
||||
<button
|
||||
type="button"
|
||||
class="cs-hit"
|
||||
:class="activeIndex === filteredRecords.length + ai ? 'cs-hit-active' : ''"
|
||||
@click="navigate(a)"
|
||||
@mouseenter="activeIndex = filteredRecords.length + ai"
|
||||
>
|
||||
<div class="cs-hit-icon" style="color: #8a8a86;">
|
||||
<UIcon :name="a.icon" style="width: 16px; height: 16px;" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[13px] text-[var(--text-primary)]">{{ a.title }}</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ a.meta }}</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-top-right-on-square" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Keyboard hint -->
|
||||
<div class="cs-footer">
|
||||
<span class="cs-kbd">↑↓</span> navigate
|
||||
<span class="cs-kbd">↵</span> go
|
||||
<span class="cs-kbd">esc</span> close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cs-dropdown {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: var(--surface, #ffffff);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.cs-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #8a8a86;
|
||||
}
|
||||
.cs-section-border {
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
margin-top: 2px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.cs-section-count {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
.cs-hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
.cs-hit:hover,
|
||||
.cs-hit-active {
|
||||
background: rgba(1,105,111,0.04);
|
||||
}
|
||||
|
||||
.cs-hit-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.03);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-kind-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 10px;
|
||||
color: #a0a09c;
|
||||
}
|
||||
|
||||
.cs-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
background: rgba(0,0,0,0.03);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: #8a8a86;
|
||||
}
|
||||
</style>
|
||||
339
app/components/layout/AppTopBar.vue
Normal file
339
app/components/layout/AppTopBar.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
defineProps<{
|
||||
sidebarCollapsed: boolean
|
||||
brandTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSidebar: []
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const { themeId, applyTheme } = useAppTheme()
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star',
|
||||
}
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||
const themeMenuOpen = ref(false)
|
||||
const themeMenuRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUserMenu() {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const userEl = userMenuRoot.value
|
||||
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
const themeEl = themeMenuRoot.value
|
||||
if (themeEl && themeMenuOpen.value && !themeEl.contains(e.target as Node)) {
|
||||
themeMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-topbar">
|
||||
<!-- Left: sidebar toggle + brand -->
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Show / hide sidebar"
|
||||
aria-label="Toggle sidebar"
|
||||
@click="emit('toggleSidebar')"
|
||||
>
|
||||
<UIcon :name="sidebarCollapsed ? 'i-heroicons-bars-3' : 'i-heroicons-chevron-double-left'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<NuxtLink to="/" class="flex items-center gap-1.5 rounded-lg px-1.5 py-1 transition hover:opacity-80">
|
||||
<span class="text-[12px] font-medium tracking-tight text-[#a0a09c]">{{ brandTitle || 'Segur-OS' }}</span>
|
||||
<span class="rounded-sm px-0.5 py-px text-[8px] font-medium uppercase tracking-wider text-[#c0c0bc]">Beta</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Center: search (absolutely centered) -->
|
||||
<div class="app-topbar-search-wrap">
|
||||
<LayoutAppCommandSearch />
|
||||
</div>
|
||||
|
||||
<!-- Center-right: contextual actions (home page) -->
|
||||
<div v-if="isHome" class="ml-auto mr-1 hidden items-center gap-1.5 sm:flex">
|
||||
<NuxtLink to="/onboarding">
|
||||
<button type="button" class="app-topbar-action-btn">
|
||||
<UIcon name="i-heroicons-arrow-trending-up" style="width: 13px; height: 13px;" />
|
||||
Pipeline
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead">
|
||||
<button type="button" class="app-topbar-action-btn">
|
||||
<UIcon name="i-heroicons-bolt" style="width: 13px; height: 13px;" />
|
||||
Quick Lead
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/quotes">
|
||||
<button type="button" class="app-topbar-action-btn app-topbar-action-primary">
|
||||
<UIcon name="i-heroicons-document-text" style="width: 13px; height: 13px;" />
|
||||
New quote
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex shrink-0 items-center gap-1" :class="isHome ? '' : 'ml-auto'">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn hidden sm:inline-flex"
|
||||
title="Refresh data"
|
||||
aria-label="Refresh"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
|
||||
<!-- Quick theme switcher -->
|
||||
<div ref="themeMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Switch theme"
|
||||
aria-label="Theme"
|
||||
@click.stop="themeMenuOpen = !themeMenuOpen"
|
||||
>
|
||||
<UIcon :name="themeIcons[themeId] ?? 'i-heroicons-swatch'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="themeMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-52 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1.5 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Theme</p>
|
||||
<button
|
||||
v-for="opt in APP_THEME_OPTIONS"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-[var(--brand-faint)]"
|
||||
:class="themeId === opt.id ? 'text-[var(--brand)] font-medium' : 'text-[var(--text-primary)]'"
|
||||
@click="applyTheme(opt.id as AppThemeId); themeMenuOpen = false"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4 shrink-0" :class="themeId === opt.id ? 'text-[var(--brand)]' : 'opacity-60'" />
|
||||
<span class="flex-1">{{ opt.label }}</span>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
name="i-heroicons-check"
|
||||
class="h-3.5 w-3.5 text-[var(--brand)]"
|
||||
/>
|
||||
</button>
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--sidebar-border)]" />
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-[var(--text-muted)] transition hover:text-[var(--brand)]"
|
||||
@click="themeMenuOpen = false"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
|
||||
All appearance settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/settings" class="inline-flex" title="Software settings">
|
||||
<span class="app-topbar-icon-btn">
|
||||
<UIcon name="i-heroicons-cog-6-tooth" style="width: 16px; height: 16px;" />
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
|
||||
|
||||
<!-- User / Account -->
|
||||
<div ref="userMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-auto items-center gap-1 rounded-md px-1.5 py-0.5 text-[#a0a09c] transition hover:bg-[rgba(0,0,0,0.04)] hover:text-[#6b6b68]"
|
||||
aria-label="Account menu"
|
||||
:aria-expanded="userMenuOpen"
|
||||
@click.stop="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 15px; height: 15px;" />
|
||||
<span class="hidden text-[11px] font-medium lg:inline">Account</span>
|
||||
<UIcon name="i-heroicons-chevron-down" style="width: 8px; height: 8px; opacity: 0.35;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="userMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
|
||||
@click="closeUserMenu"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" class="h-4 w-4 opacity-70" />
|
||||
My account
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/settings"
|
||||
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
|
||||
@click="closeUserMenu"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 opacity-70" />
|
||||
Software settings
|
||||
</NuxtLink>
|
||||
<div class="my-1 border-t border-[var(--sidebar-border)]" />
|
||||
<div class="px-3 py-1.5">
|
||||
<p class="text-[12px] font-medium text-[var(--text-primary)]">Session (mock)</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">broker@demo.com</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-right-on-rectangle" class="h-4 w-4" />
|
||||
Sign out (soon)
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.app-topbar { padding: 0 1rem; }
|
||||
}
|
||||
|
||||
.app-topbar-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 6px;
|
||||
color: #a0a09c;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
.app-topbar-icon-btn:hover {
|
||||
color: #6b6b68;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ---- Contextual action buttons (home) ---- */
|
||||
.app-topbar-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #8a8a86;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-topbar-action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0,0,0,0.03);
|
||||
border-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
.app-topbar-action-primary {
|
||||
color: #ffffff;
|
||||
background: #01696f;
|
||||
border-color: #01696f;
|
||||
}
|
||||
.app-topbar-action-primary:hover {
|
||||
color: #ffffff;
|
||||
background: #015b60;
|
||||
border-color: #015b60;
|
||||
}
|
||||
|
||||
/* ---- Centered search bar ---- */
|
||||
.app-topbar-search-wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.app-topbar-search-wrap { display: block; }
|
||||
}
|
||||
|
||||
/* Search input — quiet at rest, reveals on focus */
|
||||
.app-topbar-search-wrap :deep(input) {
|
||||
border-radius: 8px !important;
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
height: 1.75rem;
|
||||
font-size: 0.6875rem;
|
||||
background: transparent !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.app-topbar-search-wrap :deep(input):focus {
|
||||
background: var(--surface) !important;
|
||||
border-color: rgba(0, 0, 0, 0.1) !important;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
/* Round the input wrapper/container */
|
||||
.app-topbar-search-wrap :deep(.relative) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.app-topbar-search-wrap :deep(> div) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
168
app/components/quotes/QuoteComparativeLayout.vue
Normal file
168
app/components/quotes/QuoteComparativeLayout.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import type { QuoteComparativeView } from '~/types/quote-view-model'
|
||||
|
||||
defineProps<{
|
||||
model: QuoteComparativeView
|
||||
}>()
|
||||
|
||||
function fmtUsd(n: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('es-PA', { dateStyle: 'long' }).format(new Date(`${iso}T12:00:00`))
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quote-comparative space-y-8 text-[var(--text-primary)]">
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--card-border)] bg-gradient-to-br from-[var(--surface)] to-white p-6 shadow-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--brand)]">{{ model.title }}</p>
|
||||
<h2 class="mt-1 text-2xl font-bold tracking-tight text-[var(--text-primary)]">{{ model.subtitle }}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ model.tagline }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--brand-soft)] bg-[var(--brand-faint)] px-4 py-2 text-right text-sm">
|
||||
<p class="text-[var(--text-muted)]">Cotización</p>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ fmtDate(model.quoteDateIso) }}</p>
|
||||
<UBadge color="primary" variant="soft" class="mt-1">Válida {{ model.validDays }} días</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm">
|
||||
<div
|
||||
class="border-b border-[var(--brand-soft)] bg-gradient-to-r from-[var(--brand)] to-[var(--brand)] px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
1 · Cliente y cotización solicitada
|
||||
</div>
|
||||
<div class="grid gap-6 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Datos del cliente</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-[var(--text-muted)]">Nombre</dt>
|
||||
<dd class="font-medium">{{ model.client.name }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Edad</dt>
|
||||
<dd>{{ model.client.ageYears }} años</dd>
|
||||
<dt class="text-[var(--text-muted)]">Género</dt>
|
||||
<dd>{{ model.client.gender }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Fumador/a</dt>
|
||||
<dd>{{ model.client.smoker ? 'Sí' : 'No' }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Clasificación</dt>
|
||||
<dd>{{ model.client.riskClass }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Ocupación</dt>
|
||||
<dd>{{ model.client.occupation }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Lo que cotizamos</h3>
|
||||
<p class="text-3xl font-bold text-[var(--text-primary)]">{{ fmtUsd(model.request.sumAssuredUsd) }}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">Suma asegurada</p>
|
||||
<p class="mt-4 text-2xl font-semibold text-[var(--brand)]">
|
||||
{{ fmtUsd(model.request.monthlyPremiumUsd) }}
|
||||
<span class="text-base font-normal text-[var(--text-muted)]">/ mes</span>
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Prima anual equivalente: {{ fmtUsd(model.request.annualPremiumUsd) }} / año
|
||||
</p>
|
||||
<dl class="mt-4 space-y-1 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Tipo de beneficio</dt>
|
||||
<dd>{{ model.request.benefitTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Coberturas adicionales</dt>
|
||||
<dd>{{ model.request.additionalCoverageLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Depósito inicial</dt>
|
||||
<dd>{{ model.request.initialDepositLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
2 · Comparativo de valores (rescate / ahorro)
|
||||
</h3>
|
||||
<div
|
||||
v-for="(row, idx) in model.carriers"
|
||||
:key="idx"
|
||||
class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="border-b px-4 py-2 text-sm font-semibold text-white"
|
||||
:class="idx % 2 === 0 ? 'bg-slate-800' : 'bg-orange-600'"
|
||||
>
|
||||
{{ row.carrierName }} · {{ row.productName }}
|
||||
</div>
|
||||
<div class="p-4 text-xs text-[var(--text-muted)]">{{ row.ratesLine }}</div>
|
||||
<div class="overflow-x-auto px-2 pb-4">
|
||||
<table class="min-w-full text-center text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--card-border)] text-[var(--text-muted)]">
|
||||
<th class="px-2 py-2">Suma asegurada</th>
|
||||
<th v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-2">
|
||||
{{ c.yearLabel }}
|
||||
<span class="block text-[10px] font-normal text-[var(--text-muted)] opacity-70">Edad {{ c.ageLabel }}</span>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-[var(--brand)]">Destacado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-[var(--divider)]">
|
||||
<td class="px-2 py-3 font-mono text-xs">{{ fmtUsd(row.sumAssuredUsd) }}</td>
|
||||
<td v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-3 align-top">
|
||||
<span class="block text-base font-bold text-[var(--text-primary)]">{{ fmtUsd(c.guaranteed) }}</span>
|
||||
<span class="text-xs text-[var(--brand)]">{{ fmtUsd(c.projected) }}</span>
|
||||
</td>
|
||||
<td class="bg-[var(--surface)] px-3 py-3 align-top text-left text-xs text-[var(--text-primary)]">
|
||||
<p v-if="row.highlightProjectedUsd != null" class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{{ fmtUsd(row.highlightProjectedUsd) }}
|
||||
</p>
|
||||
<p v-if="row.highlightNote" class="mt-1 text-[10px] text-amber-800">
|
||||
{{ row.highlightNote }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="row.footnote" class="border-t border-[var(--divider)] px-4 py-2 text-[10px] text-[var(--text-muted)]">
|
||||
{{ row.footnote }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
||||
<p class="font-semibold text-[var(--text-primary)]">Primas acumuladas pagadas (referencia)</p>
|
||||
<div class="mt-2 flex flex-wrap gap-4 font-mono text-xs text-[var(--text-primary)]">
|
||||
<span v-for="(p, i) in model.accumulatedPremiumsUsd" :key="i">Hito {{ i + 1 }}: {{ fmtUsd(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900 text-white shadow-md">
|
||||
<div class="border-b border-slate-700 px-5 py-2 text-sm font-semibold">Análisis del asesor</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-3">
|
||||
<div
|
||||
v-for="(col, i) in model.advisorColumns"
|
||||
:key="i"
|
||||
class="rounded-lg bg-[var(--surface)]/5 p-3 text-xs leading-relaxed text-[var(--text-muted)] opacity-50"
|
||||
>
|
||||
{{ col }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
121
app/components/quotes/auto/AcceptanceStep.vue
Normal file
121
app/components/quotes/auto/AcceptanceStep.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_COVERAGE_PLANS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_QUOTE_CARRIERS,
|
||||
AUTO_SUB_RAMO_OPTIONS
|
||||
} from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
segment: AutoQuoteSegment
|
||||
}>()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return AUTO_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return AUTO_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<AutoQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
fleet: 'Fleet'
|
||||
}
|
||||
|
||||
const modeLabel: Record<AutoQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function optLabel(opts: { label: string; value: string }[], v: string) {
|
||||
if (!v) return '—'
|
||||
return opts.find((o) => o.value === v)?.label ?? v
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review and send quote requests to carrier quoting inboxes.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Client</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Phone</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.phone || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">ID</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.documentId || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ optLabel(AUTO_MARCA_OPTIONS, draft.vehicle.marca) }} {{ optLabel(AUTO_MODELO_OPTIONS, draft.vehicle.modelo) }}
|
||||
· Plate {{ draft.vehicle.placa || '—' }} · {{ draft.vehicle.year || '—' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Sub-line {{ optLabel(AUTO_SUB_RAMO_OPTIONS, draft.vehicle.subRamo) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
description="We’ll send quote requests to each carrier’s registered quoting email (configured under Settings → Providers). For comparative quotes, coverage rows follow your selected plans; when you receive pricing by email, paste figures into the comparative view."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
154
app/components/quotes/auto/CustomerVehicleStep.vue
Normal file
154
app/components/quotes/auto/CustomerVehicleStep.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_CLASE_OPTIONS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_RAMO_LABEL,
|
||||
AUTO_SUB_RAMO_OPTIONS,
|
||||
AUTO_USO_OPTIONS,
|
||||
AUTO_YEAR_OPTIONS
|
||||
} from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
/** Null until policy type is chosen — hides org field */
|
||||
segment: AutoQuoteSegment | null
|
||||
}>()
|
||||
|
||||
const showInterfaseBadge = computed(() => props.draft.vehicle.subRamo === 'cobertura_completa')
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.segment === 'corporate' || props.segment === 'fleet'
|
||||
)
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Client</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Contact on file for this quote — we’ll use it for status and carrier emails.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Legal name" required>
|
||||
<UInput v-model="draft.client.fullName" :class="inputPh" placeholder="As on government ID" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput
|
||||
v-model="draft.client.email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:class="inputPh"
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="draft.client.phone" type="tel" :class="inputPh" placeholder="+593 …" />
|
||||
</UFormField>
|
||||
<UFormField label="Government ID">
|
||||
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="Cédula, passport, or RUC" />
|
||||
</UFormField>
|
||||
<UFormField v-if="showOrganization" label="Organization" class="md:col-span-2">
|
||||
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Company or fleet name" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-8">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Vehicle</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Risk details carriers use for auto rating.</p>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Line">
|
||||
<UInput :model-value="AUTO_RAMO_LABEL" disabled class="w-full opacity-90" />
|
||||
</UFormField>
|
||||
<div class="relative pt-1">
|
||||
<UBadge
|
||||
v-if="showInterfaseBadge"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
class="pointer-events-none absolute -top-0 right-0 z-[1]"
|
||||
>
|
||||
Interfase
|
||||
</UBadge>
|
||||
<UFormField label="Sub-line">
|
||||
<USelect
|
||||
v-model="draft.vehicle.subRamo"
|
||||
:items="AUTO_SUB_RAMO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Class">
|
||||
<USelect
|
||||
v-model="draft.vehicle.clase"
|
||||
:items="AUTO_CLASE_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Use">
|
||||
<USelect
|
||||
v-model="draft.vehicle.uso"
|
||||
:items="AUTO_USO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 mt-8 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle details</p>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Make">
|
||||
<USelect
|
||||
v-model="draft.vehicle.marca"
|
||||
:items="AUTO_MARCA_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Model">
|
||||
<USelect
|
||||
v-model="draft.vehicle.modelo"
|
||||
:items="AUTO_MODELO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="License plate">
|
||||
<UInput v-model="draft.vehicle.placa" :class="inputPh" class="font-mono uppercase" placeholder="ABC-1234" />
|
||||
</UFormField>
|
||||
<UFormField label="Year">
|
||||
<USelect
|
||||
v-model="draft.vehicle.year"
|
||||
:items="AUTO_YEAR_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Capacity" description="Passengers">
|
||||
<UInput v-model="draft.vehicle.capacidadPasajeros" :class="inputPh" inputmode="numeric" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Declared value" description="USD">
|
||||
<UInput v-model="draft.vehicle.valorVehiculo" :class="inputPh" inputmode="decimal" placeholder="—" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
97
app/components/quotes/auto/SetupStep.vue
Normal file
97
app/components/quotes/auto/SetupStep.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: AutoQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: AutoQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
/** Mount vehicle + selects after first paint — avoids blocking the main thread when the route opens */
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative — same workflow; comparative opens the comparison sheet after you send requests.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Who is this policy for?</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<QuotesAutoCustomerVehicleStep v-if="showDetails" :draft="draft" :segment="draft.segment" />
|
||||
<div
|
||||
v-else
|
||||
class="min-h-[14rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading form"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
97
app/components/quotes/auto/SolicitQuotesStep.vue
Normal file
97
app/components/quotes/auto/SolicitQuotesStep.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { AUTO_COVERAGE_PLANS, AUTO_QUOTE_CARRIERS } from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers (quoting emails are maintained per provider in Settings). Pick coverage packages to request.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll prepare side-by-side comparisons using your predetermined plans. When premiums arrive by email, you can enter them into the comparative sheet."
|
||||
/>
|
||||
<UAlert
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Single quote"
|
||||
description="We’ll email each selected carrier’s quoting address on file. Attach the same vehicle and coverage ask in each request."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insurance companies</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in AUTO_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverages / plans</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in AUTO_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
127
app/components/quotes/health/AcceptanceStep.vue
Normal file
127
app/components/quotes/health/AcceptanceStep.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
segment: HealthQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return HEALTH_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return HEALTH_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<HealthQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<HealthQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the health quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings → Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Subscriber</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.age || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.health.preexistingConditions" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
Area {{ draft.health.coverageArea || '—' }} · Network {{ draft.health.networkTier || '—' }} · Deductible
|
||||
{{ draft.health.deductible || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier’s quoting address on file (Settings → Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
250
app/components/quotes/health/SetupStep.vue
Normal file
250
app/components/quotes/health/SetupStep.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HEALTH_AGE_BAND_REFERENCE,
|
||||
HEALTH_COVERAGE_AREA,
|
||||
HEALTH_DEDUCTIBLE,
|
||||
HEALTH_NETWORK_TIER,
|
||||
HEALTH_QUOTE_CARRIERS
|
||||
} from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
modeCards: { id: HealthQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: HealthQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: HealthQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: HealthQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const showPublishedTable = computed(() =>
|
||||
HEALTH_QUOTE_CARRIERS.some((c) => c.hasPublishedRateTable)
|
||||
)
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
/** Compute age from date of birth */
|
||||
watch(
|
||||
() => props.draft.health.dateOfBirth,
|
||||
(dob) => {
|
||||
if (!dob) {
|
||||
props.draft.health.age = ''
|
||||
return
|
||||
}
|
||||
const birth = new Date(dob)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const m = today.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--
|
||||
props.draft.health.age = String(age)
|
||||
}
|
||||
)
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, employer corporate, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Subscriber & contact</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Primary insured and notification email.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Legal name" required>
|
||||
<UInput v-model="draft.client.fullName" :class="inputPh" placeholder="As on ID" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput v-model="draft.client.email" type="email" :class="inputPh" placeholder="name@company.com" />
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="draft.client.phone" :class="inputPh" placeholder="+593 …" />
|
||||
</UFormField>
|
||||
<UFormField label="Government ID">
|
||||
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="ID or RUC" />
|
||||
</UFormField>
|
||||
<UFormField v-if="showOrganization" label="Organization / group name" class="md:col-span-2" required>
|
||||
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Employer or group trust" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Age & health screening</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic information carriers use for eligibility and rate bands.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Date of birth" required>
|
||||
<UInput v-model="draft.health.dateOfBirth" type="date" :class="inputPh" />
|
||||
</UFormField>
|
||||
<UFormField label="Age">
|
||||
<UInput :model-value="draft.health.age" disabled :class="inputPh" placeholder="Auto-calculated" />
|
||||
</UFormField>
|
||||
<div />
|
||||
</div>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.health.preexistingConditions" label="Preexisting medical conditions" />
|
||||
<div v-if="draft.health.preexistingConditions" class="ml-6">
|
||||
<UFormField label="Describe conditions" hint="Diabetes, hypertension, cardiac history, etc.">
|
||||
<UTextarea
|
||||
v-model="draft.health.preexistingDetails"
|
||||
:class="inputPh"
|
||||
placeholder="List conditions and approximate diagnosis dates"
|
||||
:rows="3"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage intent</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Product parameters carriers use before underwriting.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Coverage area">
|
||||
<USelect
|
||||
v-model="draft.health.coverageArea"
|
||||
:items="HEALTH_COVERAGE_AREA"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Network tier">
|
||||
<USelect
|
||||
v-model="draft.health.networkTier"
|
||||
:items="HEALTH_NETWORK_TIER"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Deductible preference">
|
||||
<USelect
|
||||
v-model="draft.health.deductible"
|
||||
:items="HEALTH_DEDUCTIBLE"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaración de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showPublishedTable" class="mt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Published rate reference (age bands)</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Some carriers publish indicative premiums by age band. Use as a guide; final quotes may still require
|
||||
underwriting. When your tenant uses table pricing or AI instead of email, turn off outbound emails under
|
||||
Settings → Quote requests.
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--sidebar-border)]">
|
||||
<table class="min-w-full text-left text-sm text-[var(--text-primary)]">
|
||||
<thead class="bg-[var(--page-bg)] text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Age band</th>
|
||||
<th class="px-3 py-2">Employee</th>
|
||||
<th class="px-3 py-2">Spouse</th>
|
||||
<th class="px-3 py-2">Child</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in HEALTH_AGE_BAND_REFERENCE" :key="row.ageBand" class="border-t border-[var(--sidebar-border)]">
|
||||
<td class="px-3 py-2 font-medium">{{ row.ageBand }}</td>
|
||||
<td class="px-3 py-2">${{ row.employee }}</td>
|
||||
<td class="px-3 py-2">${{ row.spouse }}</td>
|
||||
<td class="px-3 py-2">${{ row.children }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
90
app/components/quotes/health/SolicitQuotesStep.vue
Normal file
90
app/components/quotes/health/SolicitQuotesStep.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and product shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We’ll package one request per carrier with the same subscriber and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in HEALTH_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / benefit shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in HEALTH_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
149
app/components/quotes/life/AcceptanceStep.vue
Normal file
149
app/components/quotes/life/AcceptanceStep.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
segment: LifeQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return LIFE_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return LIFE_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<LifeQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate_keyman: 'Corporate / Key person',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<LifeQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function formatAmount(val: string) {
|
||||
const n = Number(val)
|
||||
if (!n) return val || '—'
|
||||
return '$' + n.toLocaleString()
|
||||
}
|
||||
|
||||
function termLabel(val: string) {
|
||||
if (val === 'whole') return 'Whole life'
|
||||
return val ? `${val} years` : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the life quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings -> Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insured person</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.age || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Gender</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.gender || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Smoker</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.smoker ? 'Yes' : 'No' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.life.preexistingConditions" class="sm:col-span-3">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ formatAmount(draft.life.coverageAmount) }} · {{ termLabel(draft.life.coverageTerm) }}
|
||||
</p>
|
||||
<p v-if="draft.life.beneficiaryName" class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Beneficiary: {{ draft.life.beneficiaryName }}
|
||||
<span v-if="draft.life.beneficiaryRelationship"> ({{ draft.life.beneficiaryRelationship }})</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier\'s quoting address on file (Settings -> Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
233
app/components/quotes/life/SetupStep.vue
Normal file
233
app/components/quotes/life/SetupStep.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LIFE_GENDER_OPTIONS,
|
||||
LIFE_COVERAGE_TERM_OPTIONS,
|
||||
LIFE_COVERAGE_AMOUNT_OPTIONS,
|
||||
LIFE_BENEFICIARY_RELATIONSHIP_OPTIONS
|
||||
} from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
modeCards: { id: LifeQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: LifeQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: LifeQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: LifeQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate_keyman' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
/** Compute age from date of birth */
|
||||
watch(
|
||||
() => props.draft.life.dateOfBirth,
|
||||
(dob) => {
|
||||
if (!dob) {
|
||||
props.draft.life.age = ''
|
||||
return
|
||||
}
|
||||
const birth = new Date(dob)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const m = today.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--
|
||||
props.draft.life.age = String(age)
|
||||
}
|
||||
)
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, corporate / key person, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<!-- Insured basic info -->
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Insured person</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Primary insured and notification email.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Legal name" required>
|
||||
<UInput v-model="draft.client.fullName" :class="inputPh" placeholder="As on ID" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput v-model="draft.client.email" type="email" :class="inputPh" placeholder="name@company.com" />
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="draft.client.phone" :class="inputPh" placeholder="+593 ..." />
|
||||
</UFormField>
|
||||
<UFormField label="Government ID">
|
||||
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="ID or RUC" />
|
||||
</UFormField>
|
||||
<UFormField v-if="showOrganization" label="Organization / group name" class="md:col-span-2" required>
|
||||
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Employer or group trust" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Age & health screening -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Age & health screening</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic underwriting inputs — carriers use these to determine eligibility and rate class.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Date of birth" required>
|
||||
<UInput v-model="draft.life.dateOfBirth" type="date" :class="inputPh" />
|
||||
</UFormField>
|
||||
<UFormField label="Age">
|
||||
<UInput :model-value="draft.life.age" disabled :class="inputPh" placeholder="Auto-calculated" />
|
||||
</UFormField>
|
||||
<UFormField label="Gender" required>
|
||||
<USelect
|
||||
v-model="draft.life.gender"
|
||||
:items="LIFE_GENDER_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.life.smoker" label="Smoker / tobacco user (within last 12 months)" />
|
||||
<UCheckbox v-model="draft.life.preexistingConditions" label="Preexisting medical conditions" />
|
||||
<div v-if="draft.life.preexistingConditions" class="ml-6">
|
||||
<UFormField label="Describe conditions" hint="Diabetes, hypertension, cardiac history, etc.">
|
||||
<UTextarea
|
||||
v-model="draft.life.preexistingDetails"
|
||||
:class="inputPh"
|
||||
placeholder="List conditions and approximate diagnosis dates"
|
||||
:rows="3"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage parameters -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage intent</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Sum assured, term, and beneficiary details.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Coverage amount" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverageAmount"
|
||||
:items="LIFE_COVERAGE_AMOUNT_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Coverage term" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverageTerm"
|
||||
:items="LIFE_COVERAGE_TERM_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Beneficiary name">
|
||||
<UInput v-model="draft.life.beneficiaryName" :class="inputPh" placeholder="Full legal name" />
|
||||
</UFormField>
|
||||
<UFormField label="Beneficiary relationship">
|
||||
<USelect
|
||||
v-model="draft.life.beneficiaryRelationship"
|
||||
:items="LIFE_BENEFICIARY_RELATIONSHIP_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Forms -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaracion de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation form" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
90
app/components/quotes/life/SolicitQuotesStep.vue
Normal file
90
app/components/quotes/life/SolicitQuotesStep.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and plan shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We'll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We'll package one request per carrier with the same insured and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in LIFE_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / coverage shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in LIFE_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
171
app/components/sales/SalesFlowIndicator.vue
Normal file
171
app/components/sales/SalesFlowIndicator.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Airline-ticket-style horizontal flow indicator for the sales process.
|
||||
* Always visible at the top of any sales page, highlighting "you are here."
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Which stage the current page represents */
|
||||
currentStage: 'quick_lead' | 'customer' | 'get_quotes' | 'present_quotes' | 'solicitud' | 'emission'
|
||||
}>()
|
||||
|
||||
const stages = [
|
||||
{ id: 'quick_lead', label: 'Quick Lead', icon: 'i-heroicons-bolt', route: '/sales/quick-lead' },
|
||||
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/registration/client' },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', icon: 'i-heroicons-presentation-chart-bar', route: '/quotes/compare' },
|
||||
{ id: 'solicitud', label: 'Solicitud', icon: 'i-heroicons-clipboard-document-check', route: '/onboarding/solicitud' },
|
||||
{ id: 'emission', label: 'Emission', icon: 'i-heroicons-check-badge', route: '/onboarding/emissions' },
|
||||
] as const
|
||||
|
||||
type StageId = typeof stages[number]['id']
|
||||
|
||||
function stageIndex(id: StageId): number {
|
||||
return stages.findIndex(s => s.id === id)
|
||||
}
|
||||
|
||||
const currentIdx = computed(() => stageIndex(props.currentStage))
|
||||
|
||||
function state(id: StageId): 'done' | 'current' | 'upcoming' {
|
||||
const idx = stageIndex(id)
|
||||
if (idx < currentIdx.value) return 'done'
|
||||
if (idx === currentIdx.value) return 'current'
|
||||
return 'upcoming'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sfi-root" aria-label="Sales process flow">
|
||||
<div class="sfi-track">
|
||||
<template v-for="(stage, i) in stages" :key="stage.id">
|
||||
<!-- Connector -->
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="sfi-connector"
|
||||
:class="state(stage.id) === 'upcoming' ? 'sfi-conn-upcoming' : 'sfi-conn-done'"
|
||||
/>
|
||||
|
||||
<!-- Stage node -->
|
||||
<NuxtLink
|
||||
:to="stage.route"
|
||||
class="sfi-node"
|
||||
:class="[
|
||||
`sfi-node-${state(stage.id)}`,
|
||||
state(stage.id) !== 'upcoming' ? 'sfi-node-clickable' : '',
|
||||
]"
|
||||
:aria-current="state(stage.id) === 'current' ? 'step' : undefined"
|
||||
>
|
||||
<div class="sfi-icon-circle" :class="`sfi-ic-${state(stage.id)}`">
|
||||
<UIcon v-if="state(stage.id) === 'done'" name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
||||
<UIcon v-else :name="stage.icon" style="width: 14px; height: 14px;" />
|
||||
</div>
|
||||
<span class="sfi-label">{{ stage.label }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sfi-root {
|
||||
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);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.sfi-track {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Connector line ── */
|
||||
.sfi-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 16px;
|
||||
max-width: 80px;
|
||||
margin-top: 17px; /* vertically center with the 36px circle */
|
||||
border-radius: 1px;
|
||||
}
|
||||
.sfi-conn-done {
|
||||
background: #01696f;
|
||||
}
|
||||
.sfi-conn-upcoming {
|
||||
background: rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ── Stage node ── */
|
||||
.sfi-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
min-width: 72px;
|
||||
padding: 0 4px;
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sfi-node-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sfi-node-clickable:hover .sfi-label {
|
||||
color: #01696f;
|
||||
}
|
||||
.sfi-node-clickable:hover .sfi-ic-done {
|
||||
box-shadow: 0 0 0 3px rgba(1,105,111,0.12);
|
||||
}
|
||||
|
||||
/* ── Icon circle ── */
|
||||
.sfi-icon-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
.sfi-ic-done {
|
||||
background: #01696f;
|
||||
color: #fff;
|
||||
}
|
||||
.sfi-ic-current {
|
||||
background: #fff;
|
||||
border: 2.5px solid #01696f;
|
||||
color: #01696f;
|
||||
box-shadow: 0 0 0 4px rgba(1,105,111,0.10);
|
||||
animation: sfi-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
.sfi-ic-upcoming {
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #c0c0bc;
|
||||
border: 1.5px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
@keyframes sfi-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 4px rgba(1,105,111,0.10); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(1,105,111,0.18); }
|
||||
}
|
||||
|
||||
/* ── Labels ── */
|
||||
.sfi-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.sfi-node-upcoming .sfi-label {
|
||||
color: #c0c0bc;
|
||||
}
|
||||
.sfi-node-current .sfi-label {
|
||||
color: #01696f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
</style>
|
||||
447
app/components/sales/SalesPipelineBar.vue
Normal file
447
app/components/sales/SalesPipelineBar.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
import { PIPELINE_STAGES, type SalesDeal, type PipelineStage, type DealForm } from '~/composables/useSalesPipeline'
|
||||
|
||||
const props = defineProps<{
|
||||
deal: SalesDeal
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'navigate', stage: PipelineStage): void
|
||||
}>()
|
||||
|
||||
const { stageFormProgress } = useSalesPipeline()
|
||||
|
||||
const expandedStage = ref<PipelineStage | null>(null)
|
||||
|
||||
function stageState(stageId: PipelineStage): 'completed' | 'active' | 'waiting' | 'upcoming' {
|
||||
if (props.deal.completedStages.includes(stageId)) return 'completed'
|
||||
if (props.deal.currentStage === stageId) {
|
||||
const meta = PIPELINE_STAGES.find(s => s.id === stageId)
|
||||
return meta?.isWaiting ? 'waiting' : 'active'
|
||||
}
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
function stageIdx(stageId: PipelineStage): number {
|
||||
return PIPELINE_STAGES.findIndex(s => s.id === stageId)
|
||||
}
|
||||
|
||||
function isClickable(stageId: PipelineStage): boolean {
|
||||
const state = stageState(stageId)
|
||||
return state === 'completed' || state === 'active'
|
||||
}
|
||||
|
||||
function toggleExpand(stageId: PipelineStage) {
|
||||
expandedStage.value = expandedStage.value === stageId ? null : stageId
|
||||
}
|
||||
|
||||
function stageForms(stageId: PipelineStage): DealForm[] {
|
||||
return props.deal.forms[stageId] ?? []
|
||||
}
|
||||
|
||||
function formStatusIcon(f: DealForm): string {
|
||||
if (f.status === 'complete') return 'i-heroicons-check-circle-solid'
|
||||
if (f.status === 'in_progress') return 'i-heroicons-ellipsis-horizontal-circle'
|
||||
return 'i-heroicons-minus-circle'
|
||||
}
|
||||
|
||||
function formStatusColor(f: DealForm): string {
|
||||
if (f.status === 'complete') return '#059669'
|
||||
if (f.status === 'in_progress') return '#c27b1a'
|
||||
return '#c0c0bc'
|
||||
}
|
||||
|
||||
function timeAgo(iso?: string) {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
if (diff < 3600000) return `${Math.max(1, Math.round(diff / 60000))}m ago`
|
||||
if (diff < 86400000) return `${Math.round(diff / 3600000)}h ago`
|
||||
if (diff < 172800000) return 'Yesterday'
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spb-root">
|
||||
<!-- Deal header -->
|
||||
<div class="spb-header">
|
||||
<div class="spb-deal-info">
|
||||
<span class="spb-deal-name">{{ deal.customerName }}</span>
|
||||
<span class="spb-deal-product">{{ deal.productLine }}</span>
|
||||
<span v-if="deal.carrier" class="spb-deal-carrier">{{ deal.carrier }} {{ deal.carrierProduct }}</span>
|
||||
</div>
|
||||
<span class="spb-deal-id">{{ deal.id }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Stage stepper -->
|
||||
<div class="spb-stepper">
|
||||
<template v-for="(stage, i) in PIPELINE_STAGES" :key="stage.id">
|
||||
<!-- Connector line -->
|
||||
<div v-if="i > 0" class="spb-connector" :class="stageState(stage.id) === 'upcoming' ? 'spb-conn-upcoming' : stageState(PIPELINE_STAGES[i - 1].id) === 'completed' ? 'spb-conn-done' : 'spb-conn-upcoming'" />
|
||||
|
||||
<!-- Stage node -->
|
||||
<button
|
||||
type="button"
|
||||
class="spb-stage"
|
||||
:class="[
|
||||
`spb-stage-${stageState(stage.id)}`,
|
||||
isClickable(stage.id) ? 'spb-stage-clickable' : '',
|
||||
expandedStage === stage.id ? 'spb-stage-expanded' : '',
|
||||
]"
|
||||
@click="isClickable(stage.id) ? toggleExpand(stage.id) : undefined"
|
||||
>
|
||||
<!-- Stage circle -->
|
||||
<div class="spb-circle" :class="`spb-circle-${stageState(stage.id)}`">
|
||||
<UIcon v-if="stageState(stage.id) === 'completed'" name="i-heroicons-check" style="width: 12px; height: 12px;" />
|
||||
<UIcon v-else-if="stageState(stage.id) === 'waiting'" name="i-heroicons-clock" style="width: 12px; height: 12px;" />
|
||||
<span v-else-if="stageState(stage.id) === 'active'" class="spb-circle-dot" />
|
||||
<span v-else class="spb-circle-num">{{ i + 1 }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Stage label + progress -->
|
||||
<div class="spb-stage-content">
|
||||
<span class="spb-stage-label">{{ stage.label }}</span>
|
||||
|
||||
<!-- Form progress micro-bar (only for non-waiting stages with forms) -->
|
||||
<template v-if="!stage.isWaiting && stageForms(stage.id).length > 0">
|
||||
<div class="spb-micro-bar">
|
||||
<div class="spb-micro-fill" :style="{ width: stageFormProgress(deal, stage.id) + '%' }" :class="stageFormProgress(deal, stage.id) === 100 ? 'spb-fill-done' : stageFormProgress(deal, stage.id) > 0 ? 'spb-fill-progress' : 'spb-fill-empty'" />
|
||||
</div>
|
||||
<span class="spb-micro-pct">{{ stageFormProgress(deal, stage.id) }}%</span>
|
||||
</template>
|
||||
|
||||
<!-- Waiting indicator -->
|
||||
<span v-if="stageState(stage.id) === 'waiting'" class="spb-waiting-label">
|
||||
{{ timeAgo(deal.stageTimestamps[stage.id]) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Expanded stage detail (form list) -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-1 max-h-0"
|
||||
enter-to-class="opacity-100 translate-y-0 max-h-[400px]"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 max-h-[400px]"
|
||||
leave-to-class="opacity-0 -translate-y-1 max-h-0"
|
||||
>
|
||||
<div v-if="expandedStage" class="spb-detail">
|
||||
<div class="spb-detail-header">
|
||||
<span class="spb-detail-title">{{ PIPELINE_STAGES.find(s => s.id === expandedStage)?.label }} — Forms</span>
|
||||
<button type="button" class="spb-detail-nav" @click="emit('navigate', expandedStage!)">
|
||||
Go to stage
|
||||
<UIcon name="i-heroicons-arrow-right" style="width: 11px; height: 11px;" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="stageForms(expandedStage).length === 0" class="spb-detail-empty">
|
||||
No forms assigned to this stage.
|
||||
</div>
|
||||
|
||||
<div v-else class="spb-form-list">
|
||||
<div v-for="f in stageForms(expandedStage)" :key="f.id" class="spb-form-row">
|
||||
<UIcon :name="formStatusIcon(f)" :style="{ width: '16px', height: '16px', color: formStatusColor(f), flexShrink: 0 }" />
|
||||
<div class="spb-form-info">
|
||||
<span class="spb-form-label">{{ f.label }}</span>
|
||||
<span class="spb-form-fields">{{ f.completedFields }}/{{ f.requiredFields }} fields</span>
|
||||
</div>
|
||||
<div class="spb-form-bar-wrap">
|
||||
<div class="spb-form-bar" :style="{ width: f.completionPct + '%' }" :class="f.completionPct === 100 ? 'spb-bar-done' : f.completionPct > 0 ? 'spb-bar-progress' : 'spb-bar-empty'" />
|
||||
</div>
|
||||
<span class="spb-form-pct" :class="f.completionPct === 100 ? 'spb-pct-done' : f.completionPct > 0 ? 'spb-pct-progress' : 'spb-pct-empty'">{{ f.completionPct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spb-root {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.spb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
background: rgba(0,0,0,0.01);
|
||||
}
|
||||
.spb-deal-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spb-deal-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.spb-deal-product {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(1,105,111,0.08);
|
||||
color: #01696f;
|
||||
}
|
||||
.spb-deal-carrier {
|
||||
font-size: 10px;
|
||||
color: #8a8a86;
|
||||
}
|
||||
.spb-deal-id {
|
||||
font-size: 10px;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ── Stepper ── */
|
||||
.spb-stepper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Connector line */
|
||||
.spb-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 12px;
|
||||
margin-top: 11px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.spb-conn-done { background: #01696f; }
|
||||
.spb-conn-upcoming { background: rgba(0,0,0,0.08); }
|
||||
|
||||
/* Stage button */
|
||||
.spb-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: default;
|
||||
flex-shrink: 0;
|
||||
min-width: 64px;
|
||||
}
|
||||
.spb-stage-clickable { cursor: pointer; }
|
||||
.spb-stage-clickable:hover .spb-stage-label { color: #01696f; }
|
||||
|
||||
/* Circle */
|
||||
.spb-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.spb-circle-completed {
|
||||
background: #01696f;
|
||||
color: #fff;
|
||||
}
|
||||
.spb-circle-active {
|
||||
background: #fff;
|
||||
border: 2px solid #01696f;
|
||||
color: #01696f;
|
||||
}
|
||||
.spb-circle-waiting {
|
||||
background: #fff;
|
||||
border: 2px dashed #c27b1a;
|
||||
color: #c27b1a;
|
||||
animation: spb-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.spb-circle-upcoming {
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #c0c0bc;
|
||||
}
|
||||
.spb-circle-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: #01696f;
|
||||
}
|
||||
.spb-circle-num {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #c0c0bc;
|
||||
}
|
||||
|
||||
@keyframes spb-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Stage content */
|
||||
.spb-stage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.spb-stage-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
.spb-stage-upcoming .spb-stage-label {
|
||||
color: #c0c0bc;
|
||||
}
|
||||
.spb-stage-waiting .spb-stage-label {
|
||||
color: #c27b1a;
|
||||
}
|
||||
|
||||
/* Micro progress bar */
|
||||
.spb-micro-bar {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.spb-micro-fill {
|
||||
height: 100%;
|
||||
border-radius: 1.5px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
.spb-fill-done { background: #059669; }
|
||||
.spb-fill-progress { background: #c27b1a; }
|
||||
.spb-fill-empty { background: transparent; }
|
||||
|
||||
.spb-micro-pct {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
.spb-waiting-label {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
color: #c27b1a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Expanded detail ── */
|
||||
.spb-detail {
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 12px 16px;
|
||||
background: rgba(0,0,0,0.01);
|
||||
overflow: hidden;
|
||||
}
|
||||
.spb-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.spb-detail-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.spb-detail-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(1,105,111,0.06);
|
||||
color: #01696f;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.spb-detail-nav:hover { background: rgba(1,105,111,0.12); }
|
||||
.spb-detail-empty {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Form list */
|
||||
.spb-form-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.spb-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
}
|
||||
.spb-form-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spb-form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.spb-form-fields {
|
||||
font-size: 10px;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* Form bar */
|
||||
.spb-form-bar-wrap {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: 2.5px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
min-width: 60px;
|
||||
}
|
||||
.spb-form-bar {
|
||||
height: 100%;
|
||||
border-radius: 2.5px;
|
||||
transition: width 300ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.spb-bar-done { background: #059669; }
|
||||
.spb-bar-progress { background: #c27b1a; }
|
||||
.spb-bar-empty { background: transparent; }
|
||||
|
||||
.spb-form-pct {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
.spb-pct-done { color: #059669; }
|
||||
.spb-pct-progress { color: #c27b1a; }
|
||||
.spb-pct-empty { color: #c0c0bc; }
|
||||
|
||||
/* ── Expanded highlight ── */
|
||||
.spb-stage-expanded .spb-circle {
|
||||
box-shadow: 0 0 0 3px rgba(1,105,111,0.15);
|
||||
}
|
||||
</style>
|
||||
176
app/composables/useAlertConfig.ts
Normal file
176
app/composables/useAlertConfig.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type AlertRecipient = 'handler' | 'manager' | 'customer' | 'custom'
|
||||
|
||||
export interface EmailSenderConfig {
|
||||
senderEmail: string
|
||||
senderDisplayName: string
|
||||
replyToEmail: string
|
||||
}
|
||||
|
||||
export interface AlertThresholdEntry {
|
||||
id: string
|
||||
daysBefore: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EscalationTier {
|
||||
id: string
|
||||
daysOverdue: number
|
||||
recipients: AlertRecipient[]
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface RenewalAlertConfig {
|
||||
enabled: boolean
|
||||
thresholds: AlertThresholdEntry[]
|
||||
}
|
||||
|
||||
export interface CancellationAlertConfig {
|
||||
enabled: boolean
|
||||
recipients: AlertRecipient[]
|
||||
}
|
||||
|
||||
export interface LatePaymentAlertConfig {
|
||||
enabled: boolean
|
||||
tiers: EscalationTier[]
|
||||
}
|
||||
|
||||
export interface CreditCardExpiryAlertConfig {
|
||||
enabled: boolean
|
||||
thresholds: AlertThresholdEntry[]
|
||||
autoDebitOnly: boolean
|
||||
}
|
||||
|
||||
export interface CustomAlertRule {
|
||||
id: string
|
||||
alertName: string
|
||||
field: string
|
||||
operator: 'gte' | 'lte' | 'eq' | 'gt' | 'lt' | 'contains'
|
||||
value: string | number | boolean
|
||||
recipients: AlertRecipient[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
emailSender: EmailSenderConfig
|
||||
renewals: RenewalAlertConfig
|
||||
cancellations: CancellationAlertConfig
|
||||
latePayments: LatePaymentAlertConfig
|
||||
creditCardExpiry: CreditCardExpiryAlertConfig
|
||||
customRules: CustomAlertRule[]
|
||||
}
|
||||
|
||||
/* ── Defaults ── */
|
||||
|
||||
function defaultConfig(): AlertConfig {
|
||||
return {
|
||||
emailSender: {
|
||||
senderEmail: 'alertas@miagencia.com',
|
||||
senderDisplayName: 'Segur-OS Alertas',
|
||||
replyToEmail: 'soporte@miagencia.com',
|
||||
},
|
||||
renewals: {
|
||||
enabled: true,
|
||||
thresholds: [
|
||||
{ id: 'r90', daysBefore: 90, enabled: true },
|
||||
{ id: 'r60', daysBefore: 60, enabled: true },
|
||||
{ id: 'r30', daysBefore: 30, enabled: true },
|
||||
{ id: 'r15', daysBefore: 15, enabled: true },
|
||||
],
|
||||
},
|
||||
cancellations: {
|
||||
enabled: true,
|
||||
recipients: ['handler', 'manager'],
|
||||
},
|
||||
latePayments: {
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{ id: 'lp5', daysOverdue: 5, recipients: ['handler'], action: 'Notify assigned handler' },
|
||||
{ id: 'lp15', daysOverdue: 15, recipients: ['handler', 'manager'], action: 'Notify handler + manager' },
|
||||
{ id: 'lp30', daysOverdue: 30, recipients: ['handler', 'manager', 'customer'], action: 'Auto-escalate + notify customer' },
|
||||
],
|
||||
},
|
||||
creditCardExpiry: {
|
||||
enabled: true,
|
||||
thresholds: [
|
||||
{ id: 'cc60', daysBefore: 60, enabled: true },
|
||||
{ id: 'cc30', daysBefore: 30, enabled: true },
|
||||
{ id: 'cc15', daysBefore: 15, enabled: true },
|
||||
],
|
||||
autoDebitOnly: true,
|
||||
},
|
||||
customRules: [
|
||||
{
|
||||
id: 'cr1',
|
||||
alertName: 'High-value policy renewal',
|
||||
field: 'premium',
|
||||
operator: 'gte',
|
||||
value: 25000,
|
||||
recipients: ['handler', 'manager'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
let _counter = 100
|
||||
|
||||
export function useAlertConfig() {
|
||||
const config = useLocalStorageRef<AlertConfig>('policy-ui-alert-config-v1', defaultConfig)
|
||||
|
||||
/* ── Threshold CRUD ── */
|
||||
|
||||
function addThreshold(section: 'renewals' | 'creditCardExpiry', daysBefore: number) {
|
||||
const id = `t${++_counter}`
|
||||
config.value[section].thresholds.push({ id, daysBefore, enabled: true })
|
||||
}
|
||||
|
||||
function removeThreshold(section: 'renewals' | 'creditCardExpiry', id: string) {
|
||||
config.value[section].thresholds = config.value[section].thresholds.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Late payment tier CRUD ── */
|
||||
|
||||
function addPaymentTier(daysOverdue: number, action: string, recipients: AlertRecipient[]) {
|
||||
const id = `lp${++_counter}`
|
||||
config.value.latePayments.tiers.push({ id, daysOverdue, recipients, action })
|
||||
}
|
||||
|
||||
function removePaymentTier(id: string) {
|
||||
config.value.latePayments.tiers = config.value.latePayments.tiers.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Custom rule CRUD ── */
|
||||
|
||||
function addCustomRule(rule: Omit<CustomAlertRule, 'id'>) {
|
||||
const id = `cr${++_counter}`
|
||||
config.value.customRules.push({ ...rule, id })
|
||||
}
|
||||
|
||||
function updateCustomRule(id: string, patch: Partial<CustomAlertRule>) {
|
||||
const idx = config.value.customRules.findIndex(r => r.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.customRules[idx] = { ...config.value.customRules[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeCustomRule(id: string) {
|
||||
config.value.customRules = config.value.customRules.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
addThreshold,
|
||||
removeThreshold,
|
||||
addPaymentTier,
|
||||
removePaymentTier,
|
||||
addCustomRule,
|
||||
updateCustomRule,
|
||||
removeCustomRule,
|
||||
}
|
||||
}
|
||||
147
app/composables/useAnalytics.ts
Normal file
147
app/composables/useAnalytics.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Business Analytics — composable for chart state, SVG rendering, and domain filtering.
|
||||
* SVG chart math extracted from /app/pages/index.vue dashboard charts.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import {
|
||||
ANALYTICS_METRICS,
|
||||
ANALYTICS_KPI_SUMMARIES,
|
||||
type AnalyticsDomainId,
|
||||
type AnalyticsChartType,
|
||||
type AnalyticsTimePoint,
|
||||
} from '~/data/mock-analytics'
|
||||
|
||||
export interface ChartSvgModel {
|
||||
lineD: string
|
||||
areaD: string
|
||||
points: { x: number; y: number; v: number }[]
|
||||
bars: { x: number; y: number; w: number; h: number }[]
|
||||
gridYs: number[]
|
||||
viewW: number
|
||||
viewH: number
|
||||
padX: number
|
||||
innerW: number
|
||||
bottomY: number
|
||||
}
|
||||
|
||||
interface AnalyticsState {
|
||||
activeDomain: AnalyticsDomainId
|
||||
chartBuilderMetric: string
|
||||
chartBuilderType: AnalyticsChartType
|
||||
chartBuilderRange: '3m' | '6m' | '12m'
|
||||
}
|
||||
|
||||
function buildDefaults(): AnalyticsState {
|
||||
return {
|
||||
activeDomain: 'production',
|
||||
chartBuilderMetric: 'gwp',
|
||||
chartBuilderType: 'area',
|
||||
chartBuilderRange: '6m',
|
||||
}
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const state = useLocalStorageRef<AnalyticsState>('policy-ui-analytics-v1', buildDefaults)
|
||||
|
||||
const allMetrics = ANALYTICS_METRICS
|
||||
const kpiSummaries = ANALYTICS_KPI_SUMMARIES
|
||||
|
||||
const domainMetrics = computed(() =>
|
||||
allMetrics.filter(m => m.domain === state.value.activeDomain)
|
||||
)
|
||||
|
||||
const chartBuilderMetricObj = computed(() =>
|
||||
allMetrics.find(m => m.id === state.value.chartBuilderMetric) ?? allMetrics[0]!
|
||||
)
|
||||
|
||||
const chartBuilderData = computed(() => {
|
||||
const data = chartBuilderMetricObj.value.data12m.filter(d => d.m)
|
||||
const range = state.value.chartBuilderRange
|
||||
if (range === '3m') return data.slice(-3)
|
||||
if (range === '6m') return data.slice(-6)
|
||||
return data
|
||||
})
|
||||
|
||||
// ── SVG chart model builder (extracted from dashboard index.vue) ──
|
||||
function buildSvgModel(data: AnalyticsTimePoint[], viewW = 400, viewH = 120): ChartSvgModel {
|
||||
const pts = data.map(d => d.v)
|
||||
const padX = 8; const padY = 14
|
||||
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
||||
const maxV = Math.max(...pts, 1); const minV = Math.min(...pts, 0)
|
||||
const span = maxV - minV || 1
|
||||
const points = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
||||
y: padY + (1 - (p - minV) / span) * innerH,
|
||||
v: p,
|
||||
}))
|
||||
const bottomY = padY + innerH
|
||||
const first = points[0]!; const last = points[points.length - 1]!
|
||||
|
||||
// Line + area paths (smooth Bézier curves)
|
||||
let lineD = `M ${first.x},${first.y}`
|
||||
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p0 = points[i - 1]!; const p1 = points[i]!
|
||||
const cx = (p0.x + p1.x) / 2
|
||||
const seg = ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
lineD += seg; areaD += seg
|
||||
}
|
||||
areaD += ` L ${last.x},${bottomY} Z`
|
||||
|
||||
// Bar geometry
|
||||
const barW = Math.min(innerW / pts.length * 0.6, 40)
|
||||
const bars = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW - barW / 2,
|
||||
y: padY + (1 - (p - minV) / span) * innerH,
|
||||
w: barW,
|
||||
h: ((p - minV) / span) * innerH,
|
||||
}))
|
||||
|
||||
const gridYs = [0, 0.5, 1].map(t => padY + t * innerH)
|
||||
return { lineD, areaD, points, bars, gridYs, viewW, viewH, padX, innerW, bottomY }
|
||||
}
|
||||
|
||||
// ── Sparkline helpers ──
|
||||
function sparklinePath(pts: number[], w = 112, h = 32, pad = 2): string {
|
||||
const max = Math.max(...pts); const min = Math.min(...pts); const r = max - min || 1
|
||||
const mapped = pts.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2),
|
||||
}))
|
||||
if (mapped.length < 2) return ''
|
||||
let d = `M ${mapped[0]!.x},${mapped[0]!.y}`
|
||||
for (let i = 0; i < mapped.length - 1; i++) {
|
||||
const p0 = mapped[i]!; const p1 = mapped[i + 1]!; const cx = (p0.x + p1.x) / 2
|
||||
d += ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
function sparklineArea(pts: number[], w = 112, h = 32, pad = 2): string {
|
||||
const path = sparklinePath(pts, w, h, pad)
|
||||
if (!path) return ''
|
||||
const max = Math.max(...pts); const min = Math.min(...pts); const r = max - min || 1
|
||||
const mapped = pts.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2),
|
||||
}))
|
||||
return `${path} L ${mapped[mapped.length - 1]!.x},${h} L ${mapped[0]!.x},${h} Z`
|
||||
}
|
||||
|
||||
const chartBuilderSvgModel = computed(() =>
|
||||
buildSvgModel(chartBuilderData.value, 400, 180)
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
allMetrics,
|
||||
kpiSummaries,
|
||||
domainMetrics,
|
||||
chartBuilderMetricObj,
|
||||
chartBuilderData,
|
||||
chartBuilderSvgModel,
|
||||
buildSvgModel,
|
||||
sparklinePath,
|
||||
sparklineArea,
|
||||
}
|
||||
}
|
||||
32
app/composables/useAppShellLayout.ts
Normal file
32
app/composables/useAppShellLayout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const STORAGE_KEY = 'policy-ui.sidebar.collapsed.v1'
|
||||
|
||||
export function useAppShellLayout() {
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
sidebarCollapsed.value = localStorage.getItem(STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
watch(sidebarCollapsed, (c) => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, c ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar
|
||||
}
|
||||
}
|
||||
46
app/composables/useAppTheme.ts
Normal file
46
app/composables/useAppTheme.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
const STORAGE_KEY = 'policy-ui.theme.v1'
|
||||
|
||||
const VALID: AppThemeId[] = ['light', 'purple', 'dark', 'dark-purple']
|
||||
|
||||
function isThemeId(x: string): x is AppThemeId {
|
||||
return (VALID as string[]).includes(x)
|
||||
}
|
||||
|
||||
export function useAppTheme() {
|
||||
const themeId = ref<AppThemeId>('light')
|
||||
|
||||
function applyTheme(id: AppThemeId) {
|
||||
themeId.value = id
|
||||
if (import.meta.client) {
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw && isThemeId(raw)) {
|
||||
applyTheme(raw)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyTheme('light')
|
||||
})
|
||||
|
||||
return {
|
||||
themeId,
|
||||
themeOptions: APP_THEME_OPTIONS,
|
||||
applyTheme
|
||||
}
|
||||
}
|
||||
30
app/composables/useAutoQuoteDraft.ts
Normal file
30
app/composables/useAutoQuoteDraft.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { AutoQuoteDraft } from '~/types/auto-quote-intake'
|
||||
|
||||
export function emptyAutoQuoteDraft(): AutoQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
client: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
documentId: '',
|
||||
organizationName: ''
|
||||
},
|
||||
vehicle: {
|
||||
subRamo: '',
|
||||
clase: '',
|
||||
uso: '',
|
||||
marca: '',
|
||||
modelo: '',
|
||||
placa: '',
|
||||
year: '',
|
||||
capacidadPasajeros: '',
|
||||
valorVehiculo: ''
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/composables/useBrokerageBranding.ts
Normal file
31
app/composables/useBrokerageBranding.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { BRANDING_STORAGE_KEY, type BrokerageBrandingState } from '~/types/branding'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export function defaultBrokerageBranding(): BrokerageBrandingState {
|
||||
return {
|
||||
companyName: '',
|
||||
logoDataUrl: null,
|
||||
logoFileName: '',
|
||||
reportPageHeader: '',
|
||||
reportPageFooter: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useBrokerageBranding() {
|
||||
const saved = useLocalStorageRef(BRANDING_STORAGE_KEY, defaultBrokerageBranding)
|
||||
|
||||
const productDisplayName = computed(() => {
|
||||
const n = saved.value.companyName?.trim()
|
||||
if (n) return n
|
||||
return null
|
||||
})
|
||||
|
||||
const sidebarTitle = computed(() => productDisplayName.value ?? 'Segur-OS')
|
||||
|
||||
return {
|
||||
saved,
|
||||
productDisplayName,
|
||||
sidebarTitle,
|
||||
defaultBrokerageBranding
|
||||
}
|
||||
}
|
||||
35
app/composables/useClientFavorites.ts
Normal file
35
app/composables/useClientFavorites.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Client favorites — star customers for quick dashboard access.
|
||||
* Persisted in localStorage. Stores customer IDs.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-client-favorites-v1'
|
||||
|
||||
export function useClientFavorites() {
|
||||
const favoriteIds = useLocalStorageRef<string[]>(KEY, () => [])
|
||||
|
||||
function isFavorite(customerId: string) {
|
||||
return favoriteIds.value.includes(customerId)
|
||||
}
|
||||
|
||||
function toggleFavorite(customerId: string) {
|
||||
if (isFavorite(customerId)) {
|
||||
favoriteIds.value = favoriteIds.value.filter(id => id !== customerId)
|
||||
} else {
|
||||
favoriteIds.value = [customerId, ...favoriteIds.value]
|
||||
}
|
||||
}
|
||||
|
||||
function addFavorite(customerId: string) {
|
||||
if (!isFavorite(customerId)) {
|
||||
favoriteIds.value = [customerId, ...favoriteIds.value]
|
||||
}
|
||||
}
|
||||
|
||||
function removeFavorite(customerId: string) {
|
||||
favoriteIds.value = favoriteIds.value.filter(id => id !== customerId)
|
||||
}
|
||||
|
||||
return { favoriteIds, isFavorite, toggleFavorite, addFavorite, removeFavorite }
|
||||
}
|
||||
47
app/composables/useClientRegistrationModel.ts
Normal file
47
app/composables/useClientRegistrationModel.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ClientCaptureMeta, ClientRegistrationNatural } from '~/types/brokerage-registration'
|
||||
|
||||
export function createEmptyClientRegistration(): ClientRegistrationNatural {
|
||||
return {
|
||||
id: '',
|
||||
economicGroupId: '',
|
||||
conglomerateId: '',
|
||||
personType: 'natural',
|
||||
apellidoPaterno: '',
|
||||
apellidoMaterno: '',
|
||||
primerNombre: '',
|
||||
segundoNombre: '',
|
||||
fechaNacimiento: '',
|
||||
tipoIdentificacion: '',
|
||||
cedulaOPasaporte: '',
|
||||
telefonoCelular: '',
|
||||
correoElectronicoPersonal: '',
|
||||
ocupacion: '',
|
||||
procedencia: '',
|
||||
detalle: '',
|
||||
descripcion: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useClientCaptureMeta(): ClientCaptureMeta {
|
||||
const now = new Date()
|
||||
return {
|
||||
operadorId: '32',
|
||||
operadorNombre: 'Jordan',
|
||||
fechaCaptura: now.toISOString(),
|
||||
progresoCapturaPct: 6,
|
||||
estado: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function toIndividualCustomerBody(r: ClientRegistrationNatural) {
|
||||
const last = [r.apellidoPaterno, r.apellidoMaterno].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
first_name: [r.primerNombre, r.segundoNombre].filter(Boolean).join(' ').trim() || r.primerNombre,
|
||||
last_name: last || '-',
|
||||
email: r.correoElectronicoPersonal.trim(),
|
||||
phone: r.telefonoCelular.trim(),
|
||||
birth_date: r.fechaNacimiento,
|
||||
gender: '',
|
||||
document_id: r.cedulaOPasaporte.trim()
|
||||
}
|
||||
}
|
||||
641
app/composables/useColectivos.ts
Normal file
641
app/composables/useColectivos.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Colectivos (group accounts) — data backbone for the entire module.
|
||||
* Manages group accounts, members, documents, billing, and service requests.
|
||||
* Persisted in localStorage via useLocalStorageRef.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Core Types ── */
|
||||
|
||||
export type ColectivoStatus = 'quoting' | 'onboarding' | 'active' | 'renewal_due' | 'suspended' | 'cancelled'
|
||||
export type MemberStatus = 'active' | 'pending_enrollment' | 'pending_docs' | 'excluded' | 'on_leave'
|
||||
export type ServiceRequestType = 'inclusion' | 'exclusion' | 'claim' | 'billing' | 'certificate' | 'amendment'
|
||||
export type ServiceRequestStatus = 'open' | 'in_progress' | 'pending_carrier' | 'pending_client' | 'resolved' | 'cancelled'
|
||||
export type DocumentCategory = 'policy' | 'contract' | 'endorsement' | 'certificate' | 'amendment' | 'census' | 'siniestralidad' | 'enrollment' | 'correspondence' | 'other'
|
||||
export type BillingStatus = 'upcoming' | 'invoiced' | 'paid' | 'overdue' | 'disputed' | 'reconciled'
|
||||
|
||||
export interface ColectivoMember {
|
||||
id: string
|
||||
name: string
|
||||
documentId: string
|
||||
email: string
|
||||
phone: string
|
||||
role: string
|
||||
department: string
|
||||
enrollmentDate: string
|
||||
status: MemberStatus
|
||||
tier: string
|
||||
dependents: number
|
||||
pendingDocs: string[]
|
||||
formsCompleted: number
|
||||
formsTotal: number
|
||||
}
|
||||
|
||||
export interface ColectivoDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: DocumentCategory
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
fileSize: string
|
||||
fileType: string
|
||||
version: number
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface BillingCycle {
|
||||
id: string
|
||||
period: string
|
||||
dueDate: string
|
||||
status: BillingStatus
|
||||
invoiceAmount: number
|
||||
paidAmount: number
|
||||
carrierRef: string
|
||||
membersBilled: number
|
||||
membersExpected: number
|
||||
discrepancy: number
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface ServiceRequest {
|
||||
id: string
|
||||
type: ServiceRequestType
|
||||
subject: string
|
||||
status: ServiceRequestStatus
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||
assignee: string
|
||||
created: string
|
||||
updated: string
|
||||
memberName?: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface ColectivoAccount {
|
||||
id: string
|
||||
name: string
|
||||
ruc: string
|
||||
lob: string
|
||||
product: string
|
||||
carrier: string
|
||||
status: ColectivoStatus
|
||||
|
||||
contactName: string
|
||||
contactEmail: string
|
||||
contactPhone: string
|
||||
hrContactName: string
|
||||
hrContactEmail: string
|
||||
|
||||
effectiveDate: string
|
||||
renewalDate: string
|
||||
onboardingDate: string
|
||||
|
||||
totalMembers: number
|
||||
activeMembersCount: number
|
||||
dependentsCount: number
|
||||
pendingEnrollment: number
|
||||
|
||||
monthlyPremium: number
|
||||
annualPremium: number
|
||||
commissionPct: number
|
||||
|
||||
agent: string
|
||||
|
||||
members: ColectivoMember[]
|
||||
documents: ColectivoDocument[]
|
||||
billingCycles: BillingCycle[]
|
||||
serviceRequests: ServiceRequest[]
|
||||
|
||||
recentActivity: { date: string; text: string; type: string; actor: string }[]
|
||||
|
||||
hasUrgentIssues: boolean
|
||||
outstandingClaims: number
|
||||
pendingTasks: number
|
||||
}
|
||||
|
||||
/* ── Mock Data ── */
|
||||
|
||||
function buildDefaultAccounts(): ColectivoAccount[] {
|
||||
return [
|
||||
// ── 1. Banco Regional ──
|
||||
{
|
||||
id: 'col-001',
|
||||
name: 'Banco Regional S.A.',
|
||||
ruc: '80012345-6',
|
||||
lob: 'Health',
|
||||
product: 'Salud Corporativa Elite',
|
||||
carrier: 'Vida Plena',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Roberto Méndez',
|
||||
contactEmail: 'rmendez@bancoregional.com.py',
|
||||
contactPhone: '+595 21 410-2200',
|
||||
hrContactName: 'Silvia Acosta',
|
||||
hrContactEmail: 'sacosta@bancoregional.com.py',
|
||||
|
||||
effectiveDate: '2025-07-01',
|
||||
renewalDate: '2026-05-23',
|
||||
onboardingDate: '2025-06-15',
|
||||
|
||||
totalMembers: 412,
|
||||
activeMembersCount: 398,
|
||||
dependentsCount: 687,
|
||||
pendingEnrollment: 4,
|
||||
|
||||
monthlyPremium: 10000,
|
||||
annualPremium: 120000,
|
||||
commissionPct: 12,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-001-01', name: 'Roberto Méndez', documentId: '3.456.789', email: 'rmendez@bancoregional.com.py', phone: '+595 981 222-001', role: 'Director General', department: 'Directorio', enrollmentDate: '2025-07-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-02', name: 'Silvia Acosta', documentId: '4.123.456', email: 'sacosta@bancoregional.com.py', phone: '+595 981 222-002', role: 'Gerente RRHH', department: 'Recursos Humanos', enrollmentDate: '2025-07-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-03', name: 'Jorge Ramírez', documentId: '2.987.654', email: 'jramirez@bancoregional.com.py', phone: '+595 981 222-003', role: 'Analista de Créditos', department: 'Banca Comercial', enrollmentDate: '2025-07-15', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-04', name: 'María Elena Torres', documentId: '5.321.098', email: 'metorres@bancoregional.com.py', phone: '+595 981 222-004', role: 'Cajera Principal', department: 'Operaciones', enrollmentDate: '2025-07-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-05', name: 'Fernando López', documentId: '1.654.321', email: 'flopez@bancoregional.com.py', phone: '+595 981 222-005', role: 'Gerente de Sucursal', department: 'Sucursales', enrollmentDate: '2025-08-01', status: 'active', tier: 'Plus', dependents: 4, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-06', name: 'Patricia Benítez', documentId: '6.789.012', email: 'pbenitez@bancoregional.com.py', phone: '+595 981 222-006', role: 'Oficial de Cumplimiento', department: 'Legal', enrollmentDate: '2025-09-01', status: 'pending_docs', tier: 'Plus', dependents: 1, pendingDocs: ['Certificado médico', 'Formulario de dependientes'], formsCompleted: 2, formsTotal: 4 },
|
||||
{ id: 'mbr-001-07', name: 'Luis Giménez', documentId: '3.210.987', email: 'lgimenez@bancoregional.com.py', phone: '+595 981 222-007', role: 'Desarrollador Senior', department: 'Tecnología', enrollmentDate: '2026-01-15', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-08', name: 'Ana Cristina Duarte', documentId: '7.654.321', email: 'acduarte@bancoregional.com.py', phone: '+595 981 222-008', role: 'Asistente Ejecutiva', department: 'Directorio', enrollmentDate: '2025-07-01', status: 'on_leave', tier: 'Executive', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-001-01', name: 'Póliza Colectiva 2025-2026', category: 'policy', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-06-28', fileSize: '4.2 MB', fileType: 'PDF', version: 2, notes: 'Versión final firmada' },
|
||||
{ id: 'doc-001-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-06-20', fileSize: '1.8 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-001-03', name: 'Censo Marzo 2026', category: 'census', uploadedBy: 'Silvia Acosta', uploadedAt: '2026-03-05', fileSize: '856 KB', fileType: 'XLSX', version: 1, notes: 'Incluye 3 nuevas altas' },
|
||||
{ id: 'doc-001-04', name: 'Endoso #3 - Inclusiones Feb 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-02-18', fileSize: '320 KB', fileType: 'PDF', version: 1, notes: '8 inclusiones procesadas' },
|
||||
{ id: 'doc-001-05', name: 'Reporte Siniestralidad Q1 2026', category: 'siniestralidad', uploadedBy: 'Vida Plena', uploadedAt: '2026-04-02', fileSize: '2.1 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad al 68%' },
|
||||
{ id: 'doc-001-06', name: 'Certificado Individual - R. Méndez', category: 'certificate', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-07-10', fileSize: '145 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-001-01', period: 'January 2026', dueDate: '2026-01-15', status: 'paid', invoiceAmount: 10000, paidAmount: 10000, carrierRef: 'VP-2026-0412-01', membersBilled: 410, membersExpected: 410, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-001-02', period: 'February 2026', dueDate: '2026-02-15', status: 'paid', invoiceAmount: 10200, paidAmount: 10200, carrierRef: 'VP-2026-0412-02', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: 'Incluyó 2 altas' },
|
||||
{ id: 'bill-001-03', period: 'March 2026', dueDate: '2026-03-15', status: 'paid', invoiceAmount: 10200, paidAmount: 10200, carrierRef: 'VP-2026-0412-03', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-001-04', period: 'April 2026', dueDate: '2026-04-15', status: 'invoiced', invoiceAmount: 10200, paidAmount: 0, carrierRef: 'VP-2026-0412-04', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: 'Factura enviada al cliente' },
|
||||
{ id: 'bill-001-05', period: 'May 2026', dueDate: '2026-05-15', status: 'upcoming', invoiceAmount: 10200, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 412, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-001-01', type: 'claim', subject: 'Reclamo cobertura cirugía - Expediente #4521', status: 'pending_carrier', priority: 'urgent', assignee: 'Carlos Villalba', created: '2026-03-28', updated: '2026-04-05', memberName: 'Fernando López', notes: 'Carrier solicitó documentación adicional del hospital. Plazo vence 04/12.' },
|
||||
{ id: 'sr-001-02', type: 'inclusion', subject: 'Alta de 3 nuevos empleados - Sucursal Este', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-04-01', updated: '2026-04-06', notes: 'Faltan formularios de 1 empleado.' },
|
||||
{ id: 'sr-001-03', type: 'certificate', subject: 'Certificados individuales para viaje corporativo', status: 'open', priority: 'urgent', assignee: 'Carlos Villalba', created: '2026-04-07', updated: '2026-04-07', notes: 'Necesitan 12 certificados para viaje el 04/18.' },
|
||||
{ id: 'sr-001-04', type: 'billing', subject: 'Consulta sobre diferencia en factura Enero', status: 'resolved', priority: 'low', assignee: 'Carlos Villalba', created: '2026-01-22', updated: '2026-02-03', notes: 'Diferencia por ajuste de prima. Aclarado con RRHH.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-07', text: 'Solicitud urgente de certificados para viaje corporativo', type: 'service_request', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-04-06', text: 'Actualización en reclamo #4521 - carrier solicita más docs', type: 'claim_update', actor: 'Vida Plena' },
|
||||
{ date: '2026-04-05', text: 'Factura de Abril enviada al cliente', type: 'billing', actor: 'Sistema' },
|
||||
{ date: '2026-04-01', text: 'Nueva solicitud de inclusión: 3 empleados Sucursal Este', type: 'inclusion', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-03-28', text: 'Reclamo de cirugía elevado a urgente', type: 'claim_update', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-05', text: 'Censo de Marzo recibido y cargado', type: 'document', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-02-18', text: 'Endoso #3 procesado - 8 inclusiones', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: true,
|
||||
outstandingClaims: 2,
|
||||
pendingTasks: 5,
|
||||
},
|
||||
|
||||
// ── 2. Clínica San José ──
|
||||
{
|
||||
id: 'col-002',
|
||||
name: 'Clínica San José',
|
||||
ruc: '80034567-1',
|
||||
lob: 'Health',
|
||||
product: 'Salud Integral Empresarial',
|
||||
carrier: 'Salud Global',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Dr. Marcelo Insfrán',
|
||||
contactEmail: 'minsfran@clinicasanjose.com.py',
|
||||
contactPhone: '+595 21 550-3300',
|
||||
hrContactName: 'Laura Paredes',
|
||||
hrContactEmail: 'lparedes@clinicasanjose.com.py',
|
||||
|
||||
effectiveDate: '2025-09-01',
|
||||
renewalDate: '2026-09-01',
|
||||
onboardingDate: '2025-08-15',
|
||||
|
||||
totalMembers: 88,
|
||||
activeMembersCount: 88,
|
||||
dependentsCount: 142,
|
||||
pendingEnrollment: 0,
|
||||
|
||||
monthlyPremium: 3500,
|
||||
annualPremium: 42000,
|
||||
commissionPct: 10,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-002-01', name: 'Dr. Marcelo Insfrán', documentId: '1.234.567', email: 'minsfran@clinicasanjose.com.py', phone: '+595 982 333-001', role: 'Director Médico', department: 'Dirección', enrollmentDate: '2025-09-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-02', name: 'Laura Paredes', documentId: '2.345.678', email: 'lparedes@clinicasanjose.com.py', phone: '+595 982 333-002', role: 'Jefa de RRHH', department: 'Administración', enrollmentDate: '2025-09-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-03', name: 'Dra. Carolina Fleitas', documentId: '3.456.012', email: 'cfleitas@clinicasanjose.com.py', phone: '+595 982 333-003', role: 'Pediatra', department: 'Pediatría', enrollmentDate: '2025-09-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-04', name: 'Enf. Rosa Martínez', documentId: '4.567.890', email: 'rmartinez@clinicasanjose.com.py', phone: '+595 982 333-004', role: 'Enfermera Jefa', department: 'Enfermería', enrollmentDate: '2025-09-15', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-05', name: 'Carlos Ruiz', documentId: '5.678.901', email: 'cruiz@clinicasanjose.com.py', phone: '+595 982 333-005', role: 'Técnico de Laboratorio', department: 'Laboratorio', enrollmentDate: '2025-10-01', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-06', name: 'Gabriela Sánchez', documentId: '6.789.012', email: 'gsanchez@clinicasanjose.com.py', phone: '+595 982 333-006', role: 'Recepcionista', department: 'Atención al Paciente', enrollmentDate: '2025-09-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-002-01', name: 'Póliza Colectiva Clínica SJ 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-08-28', fileSize: '3.1 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-002-02', name: 'Contrato de Servicios', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-08-20', fileSize: '1.4 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-002-03', name: 'Censo Actualizado Q1 2026', category: 'census', uploadedBy: 'Laura Paredes', uploadedAt: '2026-03-28', fileSize: '420 KB', fileType: 'XLSX', version: 1, notes: 'Sin cambios respecto al período anterior' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-002-01', period: 'February 2026', dueDate: '2026-02-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-02', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-002-02', period: 'March 2026', dueDate: '2026-03-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-03', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-002-03', period: 'April 2026', dueDate: '2026-04-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-04', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-002-01', type: 'certificate', subject: 'Renovación de certificados anuales', status: 'resolved', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-02-10', updated: '2026-02-20', notes: 'Todos los certificados emitidos y entregados.' },
|
||||
{ id: 'sr-002-02', type: 'amendment', subject: 'Actualización de coberturas de maternidad', status: 'resolved', priority: 'low', assignee: 'María Fernanda Ortiz', created: '2026-01-15', updated: '2026-02-01', notes: 'Endoso emitido por carrier.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-01', text: 'Factura de Abril pagada a tiempo', type: 'billing', actor: 'Laura Paredes' },
|
||||
{ date: '2026-03-28', text: 'Censo Q1 2026 cargado - sin cambios', type: 'document', actor: 'Laura Paredes' },
|
||||
{ date: '2026-02-20', text: 'Certificados anuales entregados', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-02-01', text: 'Endoso de maternidad procesado', type: 'endorsement', actor: 'Salud Global' },
|
||||
{ date: '2026-01-15', text: 'Solicitud de actualización de coberturas', type: 'service_request', actor: 'Dr. Marcelo Insfrán' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 0,
|
||||
},
|
||||
|
||||
// ── 3. ITSA Corp ──
|
||||
{
|
||||
id: 'col-003',
|
||||
name: 'ITSA Corp S.A.',
|
||||
ruc: '80056789-3',
|
||||
lob: 'Disability',
|
||||
product: 'Protección Laboral Integral',
|
||||
carrier: 'Continental Life',
|
||||
status: 'onboarding',
|
||||
|
||||
contactName: 'Ing. Andrés Caballero',
|
||||
contactEmail: 'acaballero@itsacorp.com.py',
|
||||
contactPhone: '+595 21 620-1100',
|
||||
hrContactName: 'Verónica Meza',
|
||||
hrContactEmail: 'vmeza@itsacorp.com.py',
|
||||
|
||||
effectiveDate: '2026-05-01',
|
||||
renewalDate: '2027-05-01',
|
||||
onboardingDate: '2026-03-15',
|
||||
|
||||
totalMembers: 230,
|
||||
activeMembersCount: 210,
|
||||
dependentsCount: 0,
|
||||
pendingEnrollment: 15,
|
||||
|
||||
monthlyPremium: 2625,
|
||||
annualPremium: 31500,
|
||||
commissionPct: 8,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-003-01', name: 'Ing. Andrés Caballero', documentId: '1.111.222', email: 'acaballero@itsacorp.com.py', phone: '+595 983 444-001', role: 'CEO', department: 'Dirección', enrollmentDate: '2026-03-20', status: 'active', tier: 'Executive', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-02', name: 'Verónica Meza', documentId: '2.222.333', email: 'vmeza@itsacorp.com.py', phone: '+595 983 444-002', role: 'Gerente RRHH', department: 'RRHH', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-03', name: 'Diego Portillo', documentId: '3.333.444', email: 'dportillo@itsacorp.com.py', phone: '+595 983 444-003', role: 'Operario Línea A', department: 'Producción', enrollmentDate: '2026-03-25', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Declaración de salud', 'Copia de CI', 'Formulario de inscripción'], formsCompleted: 1, formsTotal: 5 },
|
||||
{ id: 'mbr-003-04', name: 'Sandra Lezcano', documentId: '4.444.555', email: 'slezcano@itsacorp.com.py', phone: '+595 983 444-004', role: 'Supervisora de Calidad', department: 'Calidad', enrollmentDate: '2026-03-22', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-05', name: 'Ramón Villasboa', documentId: '5.555.666', email: 'rvillasboa@itsacorp.com.py', phone: '+595 983 444-005', role: 'Técnico de Mantenimiento', department: 'Mantenimiento', enrollmentDate: '2026-04-01', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Declaración de salud', 'Formulario de inscripción'], formsCompleted: 2, formsTotal: 5 },
|
||||
{ id: 'mbr-003-06', name: 'Claudia Estigarribia', documentId: '6.666.777', email: 'cestigarribia@itsacorp.com.py', phone: '+595 983 444-006', role: 'Contadora', department: 'Finanzas', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-07', name: 'Miguel Ayala', documentId: '7.777.888', email: 'mayala@itsacorp.com.py', phone: '+595 983 444-007', role: 'Jefe de Planta', department: 'Producción', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-08', name: 'Lorena Cáceres', documentId: '8.888.999', email: 'lcaceres@itsacorp.com.py', phone: '+595 983 444-008', role: 'Asistente Administrativa', department: 'Administración', enrollmentDate: '2026-04-03', status: 'pending_docs', tier: 'Basic', dependents: 0, pendingDocs: ['Certificado de antecedentes'], formsCompleted: 4, formsTotal: 5 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-003-01', name: 'Propuesta Continental Life - Disability', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-03-10', fileSize: '2.8 MB', fileType: 'PDF', version: 1, notes: 'Propuesta aceptada por cliente' },
|
||||
{ id: 'doc-003-02', name: 'Censo Inicial ITSA Corp', category: 'census', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-18', fileSize: '1.2 MB', fileType: 'XLSX', version: 2, notes: 'V2 - corregidos datos de 12 empleados' },
|
||||
{ id: 'doc-003-03', name: 'Formularios de Inscripción (Lote 1)', category: 'enrollment', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-25', fileSize: '15.4 MB', fileType: 'PDF', version: 1, notes: '180 formularios escaneados' },
|
||||
{ id: 'doc-003-04', name: 'Declaraciones de Salud (Lote 1)', category: 'enrollment', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-28', fileSize: '22.1 MB', fileType: 'PDF', version: 1, notes: '175 declaraciones recibidas' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-003-01', period: 'May 2026', dueDate: '2026-05-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: 'Primer ciclo de facturación' },
|
||||
{ id: 'bill-003-02', period: 'June 2026', dueDate: '2026-06-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-003-03', period: 'July 2026', dueDate: '2026-07-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-003-01', type: 'inclusion', subject: 'Completar inscripción de 15 empleados pendientes', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-04-01', updated: '2026-04-07', notes: 'RRHH está recopilando formularios faltantes. Fecha límite: 04/15.' },
|
||||
{ id: 'sr-003-02', type: 'amendment', subject: 'Solicitud de inclusión de cobertura dental', status: 'open', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-04-05', updated: '2026-04-05', notes: 'Cliente consulta costo adicional para rider dental.' },
|
||||
{ id: 'sr-003-03', type: 'certificate', subject: 'Emisión de certificados individuales - Lote inicial', status: 'pending_carrier', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-28', updated: '2026-04-03', notes: 'Carrier procesando 210 certificados.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-07', text: 'Seguimiento de formularios pendientes con RRHH', type: 'onboarding', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-04-05', text: 'Nueva solicitud: consulta sobre rider dental', type: 'service_request', actor: 'Ing. Andrés Caballero' },
|
||||
{ date: '2026-04-03', text: 'Certificados enviados a Continental Life para emisión', type: 'service_request', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-28', text: 'Lote 1 de declaraciones de salud cargado (175)', type: 'document', actor: 'Verónica Meza' },
|
||||
{ date: '2026-03-25', text: 'Lote 1 de formularios de inscripción cargado (180)', type: 'document', actor: 'Verónica Meza' },
|
||||
{ date: '2026-03-20', text: 'Onboarding iniciado - primeros empleados registrados', type: 'onboarding', actor: 'Carlos Villalba' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 18,
|
||||
},
|
||||
|
||||
// ── 4. Municipalidad Central ──
|
||||
{
|
||||
id: 'col-004',
|
||||
name: 'Municipalidad Central',
|
||||
ruc: '80078901-5',
|
||||
lob: 'Health',
|
||||
product: 'Salud Pública Municipal',
|
||||
carrier: 'Vida Plena',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Lic. Gustavo Ferreira',
|
||||
contactEmail: 'gferreira@muniasuncion.gov.py',
|
||||
contactPhone: '+595 21 440-5500',
|
||||
hrContactName: 'Norma Jiménez',
|
||||
hrContactEmail: 'njimenez@muniasuncion.gov.py',
|
||||
|
||||
effectiveDate: '2025-01-01',
|
||||
renewalDate: '2026-01-01',
|
||||
onboardingDate: '2024-11-15',
|
||||
|
||||
totalMembers: 640,
|
||||
activeMembersCount: 625,
|
||||
dependentsCount: 1120,
|
||||
pendingEnrollment: 8,
|
||||
|
||||
monthlyPremium: 16500,
|
||||
annualPremium: 198000,
|
||||
commissionPct: 9,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-004-01', name: 'Lic. Gustavo Ferreira', documentId: '1.010.101', email: 'gferreira@muniasuncion.gov.py', phone: '+595 984 555-001', role: 'Secretario General', department: 'Secretaría General', enrollmentDate: '2025-01-01', status: 'active', tier: 'Executive', dependents: 4, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-02', name: 'Norma Jiménez', documentId: '2.020.202', email: 'njimenez@muniasuncion.gov.py', phone: '+595 984 555-002', role: 'Directora de RRHH', department: 'RRHH', enrollmentDate: '2025-01-01', status: 'active', tier: 'Plus', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-03', name: 'Pedro Gauto', documentId: '3.030.303', email: 'pgauto@muniasuncion.gov.py', phone: '+595 984 555-003', role: 'Inspector de Obras', department: 'Obras Públicas', enrollmentDate: '2025-01-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-04', name: 'María Bogado', documentId: '4.040.404', email: 'mbogado@muniasuncion.gov.py', phone: '+595 984 555-004', role: 'Asistente Social', department: 'Acción Social', enrollmentDate: '2025-02-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-05', name: 'Juan Arce', documentId: '5.050.505', email: 'jarce@muniasuncion.gov.py', phone: '+595 984 555-005', role: 'Conductor', department: 'Transporte', enrollmentDate: '2025-01-15', status: 'active', tier: 'Basic', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-06', name: 'Blanca Ovelar', documentId: '6.060.606', email: 'bovelar@muniasuncion.gov.py', phone: '+595 984 555-006', role: 'Contadora Municipal', department: 'Finanzas', enrollmentDate: '2025-01-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-07', name: 'Raúl Cabrera', documentId: '7.070.707', email: 'rcabrera@muniasuncion.gov.py', phone: '+595 984 555-007', role: 'Jardinero Municipal', department: 'Espacios Verdes', enrollmentDate: '2025-03-01', status: 'pending_docs', tier: 'Basic', dependents: 1, pendingDocs: ['Formulario de dependientes actualizado'], formsCompleted: 3, formsTotal: 4 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-004-01', name: 'Póliza Colectiva Municipal 2025', category: 'policy', uploadedBy: 'Carlos Villalba', uploadedAt: '2024-12-20', fileSize: '5.8 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-02', name: 'Convenio Marco Municipalidad-Brokerage', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2024-11-25', fileSize: '3.2 MB', fileType: 'PDF', version: 1, notes: 'Aprobado por resolución municipal #2024-1820' },
|
||||
{ id: 'doc-004-03', name: 'Censo Febrero 2026', category: 'census', uploadedBy: 'Norma Jiménez', uploadedAt: '2026-02-28', fileSize: '2.1 MB', fileType: 'XLSX', version: 1, notes: '8 altas, 3 bajas' },
|
||||
{ id: 'doc-004-04', name: 'Endoso #5 - Ajuste Feb 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-03-10', fileSize: '280 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-05', name: 'Reporte Siniestralidad 2025 Anual', category: 'siniestralidad', uploadedBy: 'Vida Plena', uploadedAt: '2026-02-15', fileSize: '4.5 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad 82% - bandera amarilla' },
|
||||
{ id: 'doc-004-06', name: 'Carta de Reclamo - Diferencia Facturación', category: 'correspondence', uploadedBy: 'Blanca Ovelar', uploadedAt: '2026-03-22', fileSize: '95 KB', fileType: 'PDF', version: 1, notes: 'Reclamo formal por discrepancia en marzo' },
|
||||
{ id: 'doc-004-07', name: 'Endoso #6 - Inclusiones Mar 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-04-01', fileSize: '310 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-08', name: 'Censo Marzo 2026', category: 'census', uploadedBy: 'Norma Jiménez', uploadedAt: '2026-03-31', fileSize: '2.2 MB', fileType: 'XLSX', version: 1, notes: '5 altas nuevas' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-004-01', period: 'January 2026', dueDate: '2026-01-10', status: 'paid', invoiceAmount: 16200, paidAmount: 16200, carrierRef: 'VP-2026-0640-01', membersBilled: 635, membersExpected: 635, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-02', period: 'February 2026', dueDate: '2026-02-10', status: 'paid', invoiceAmount: 16350, paidAmount: 16350, carrierRef: 'VP-2026-0640-02', membersBilled: 638, membersExpected: 638, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-03', period: 'March 2026', dueDate: '2026-03-10', status: 'disputed', invoiceAmount: 16700, paidAmount: 0, carrierRef: 'VP-2026-0640-03', membersBilled: 648, membersExpected: 640, discrepancy: 8, notes: 'Carrier facturó 8 miembros de más. Cliente reclama diferencia de $208.' },
|
||||
{ id: 'bill-004-04', period: 'April 2026', dueDate: '2026-04-10', status: 'overdue', invoiceAmount: 16500, paidAmount: 0, carrierRef: 'VP-2026-0640-04', membersBilled: 640, membersExpected: 640, discrepancy: 0, notes: 'Cliente retiene pago hasta resolución de disputa de Marzo.' },
|
||||
{ id: 'bill-004-05', period: 'May 2026', dueDate: '2026-05-10', status: 'upcoming', invoiceAmount: 16500, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 640, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-06', period: 'June 2026', dueDate: '2026-06-10', status: 'upcoming', invoiceAmount: 16500, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 640, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-004-01', type: 'billing', subject: 'Discrepancia facturación Marzo - 8 miembros de más', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-03-15', updated: '2026-04-06', notes: 'Carrier reconoce error. Nota de crédito en proceso. Cliente retiene pago de Abril hasta resolución.' },
|
||||
{ id: 'sr-004-02', type: 'inclusion', subject: 'Alta de 5 nuevos empleados - Marzo 2026', status: 'resolved', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-05', updated: '2026-03-18', notes: 'Endoso procesado. Todos los certificados emitidos.' },
|
||||
{ id: 'sr-004-03', type: 'exclusion', subject: 'Baja de 3 empleados retirados', status: 'resolved', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-02-20', updated: '2026-03-01', notes: 'Bajas procesadas en endoso #5.' },
|
||||
{ id: 'sr-004-04', type: 'claim', subject: 'Reclamo hospitalización - Expediente #7810', status: 'pending_carrier', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-20', updated: '2026-04-02', memberName: 'Pedro Gauto', notes: 'Documentación completa enviada a carrier.' },
|
||||
{ id: 'sr-004-05', type: 'amendment', subject: 'Solicitud de mejora de cobertura oftalmológica', status: 'open', priority: 'low', assignee: 'Carlos Villalba', created: '2026-04-03', updated: '2026-04-03', notes: 'Sindicato solicitó mejoras. Pendiente cotización de carrier.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-06', text: 'Carrier confirmó nota de crédito en proceso por discrepancia Marzo', type: 'billing', actor: 'Vida Plena' },
|
||||
{ date: '2026-04-03', text: 'Sindicato solicita mejora en cobertura oftalmológica', type: 'service_request', actor: 'Norma Jiménez' },
|
||||
{ date: '2026-04-01', text: 'Endoso #6 procesado - inclusiones de Marzo', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-31', text: 'Censo Marzo 2026 recibido', type: 'document', actor: 'Norma Jiménez' },
|
||||
{ date: '2026-03-22', text: 'Carta formal de reclamo por diferencia en facturación', type: 'correspondence', actor: 'Blanca Ovelar' },
|
||||
{ date: '2026-03-15', text: 'Discrepancia detectada en factura de Marzo: 8 miembros de más', type: 'billing', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-10', text: 'Endoso #5 procesado', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-02-15', text: 'Reporte de siniestralidad anual 2025 recibido - 82%', type: 'document', actor: 'Vida Plena' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 1,
|
||||
pendingTasks: 4,
|
||||
},
|
||||
|
||||
// ── 5. Grupo Agrícola del Sur ──
|
||||
{
|
||||
id: 'col-005',
|
||||
name: 'Grupo Agrícola del Sur S.A.',
|
||||
ruc: '80090123-8',
|
||||
lob: 'Life',
|
||||
product: 'Vida Grupal Protección Familiar',
|
||||
carrier: 'Seguros del Pacífico',
|
||||
status: 'renewal_due',
|
||||
|
||||
contactName: 'Ing. Agr. Héctor Bogado',
|
||||
contactEmail: 'hbogado@gagricsur.com.py',
|
||||
contactPhone: '+595 71 205-600',
|
||||
hrContactName: 'Celeste Riveros',
|
||||
hrContactEmail: 'criveros@gagricsur.com.py',
|
||||
|
||||
effectiveDate: '2025-05-01',
|
||||
renewalDate: '2026-05-01',
|
||||
onboardingDate: '2025-04-10',
|
||||
|
||||
totalMembers: 175,
|
||||
activeMembersCount: 170,
|
||||
dependentsCount: 310,
|
||||
pendingEnrollment: 0,
|
||||
|
||||
monthlyPremium: 4833,
|
||||
annualPremium: 58000,
|
||||
commissionPct: 11,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-005-01', name: 'Ing. Agr. Héctor Bogado', documentId: '1.515.151', email: 'hbogado@gagricsur.com.py', phone: '+595 985 666-001', role: 'Director General', department: 'Dirección', enrollmentDate: '2025-05-01', status: 'active', tier: 'Executive', dependents: 4, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-02', name: 'Celeste Riveros', documentId: '2.525.252', email: 'criveros@gagricsur.com.py', phone: '+595 985 666-002', role: 'Gerente de RRHH', department: 'RRHH', enrollmentDate: '2025-05-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-03', name: 'Tomás Aquino', documentId: '3.535.353', email: 'taquino@gagricsur.com.py', phone: '+595 985 666-003', role: 'Capataz de Campo', department: 'Operaciones de Campo', enrollmentDate: '2025-05-01', status: 'active', tier: 'Basic', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-04', name: 'Rosa Benítez', documentId: '4.545.454', email: 'rbenitez@gagricsur.com.py', phone: '+595 985 666-004', role: 'Ingeniera Agrónoma', department: 'Técnica', enrollmentDate: '2025-05-15', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-05', name: 'Óscar Domínguez', documentId: '5.555.565', email: 'odominguez@gagricsur.com.py', phone: '+595 985 666-005', role: 'Chofer de Camión', department: 'Logística', enrollmentDate: '2025-06-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-06', name: 'Luz Marina Espínola', documentId: '6.565.656', email: 'lespinola@gagricsur.com.py', phone: '+595 985 666-006', role: 'Contadora', department: 'Administración', enrollmentDate: '2025-05-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-07', name: 'Esteban Villalba', documentId: '7.575.757', email: 'evillalba@gagricsur.com.py', phone: '+595 985 666-007', role: 'Peón Rural', department: 'Operaciones de Campo', enrollmentDate: '2025-07-01', status: 'excluded', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-005-01', name: 'Póliza Vida Grupal 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-04-28', fileSize: '3.5 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-005-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-04-15', fileSize: '1.6 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-005-03', name: 'Censo Actualizado Marzo 2026', category: 'census', uploadedBy: 'Celeste Riveros', uploadedAt: '2026-03-20', fileSize: '680 KB', fileType: 'XLSX', version: 1, notes: '1 exclusión (Villalba, E.) por renuncia' },
|
||||
{ id: 'doc-005-04', name: 'Propuesta de Renovación 2026-2027', category: 'other', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2026-03-25', fileSize: '2.2 MB', fileType: 'PDF', version: 1, notes: 'Incluye 3 opciones de carrier' },
|
||||
{ id: 'doc-005-05', name: 'Siniestralidad Acumulada 2025-2026', category: 'siniestralidad', uploadedBy: 'Seguros del Pacífico', uploadedAt: '2026-03-15', fileSize: '1.8 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad al 45% - muy favorable' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-005-01', period: 'February 2026', dueDate: '2026-02-15', status: 'paid', invoiceAmount: 4833, paidAmount: 4833, carrierRef: 'SP-2026-0175-02', membersBilled: 175, membersExpected: 175, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-005-02', period: 'March 2026', dueDate: '2026-03-15', status: 'paid', invoiceAmount: 4810, paidAmount: 4810, carrierRef: 'SP-2026-0175-03', membersBilled: 174, membersExpected: 174, discrepancy: 0, notes: '1 baja procesada' },
|
||||
{ id: 'bill-005-03', period: 'April 2026', dueDate: '2026-04-15', status: 'invoiced', invoiceAmount: 4810, paidAmount: 0, carrierRef: 'SP-2026-0175-04', membersBilled: 174, membersExpected: 174, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-005-01', type: 'claim', subject: 'Reclamo fallecimiento - Beneficiario Flia. Aquino', status: 'in_progress', priority: 'high', assignee: 'María Fernanda Ortiz', created: '2026-02-10', updated: '2026-04-01', memberName: 'Tomás Aquino', notes: 'Documentación de siniestro completa. Carrier en revisión. Monto: Gs. 350.000.000.' },
|
||||
{ id: 'sr-005-02', type: 'exclusion', subject: 'Baja por renuncia - Esteban Villalba', status: 'resolved', priority: 'low', assignee: 'María Fernanda Ortiz', created: '2026-03-01', updated: '2026-03-15', memberName: 'Esteban Villalba', notes: 'Procesado en endoso.' },
|
||||
{ id: 'sr-005-03', type: 'amendment', subject: 'Propuesta de renovación 2026-2027 - Negociación', status: 'in_progress', priority: 'high', assignee: 'María Fernanda Ortiz', created: '2026-03-25', updated: '2026-04-05', notes: 'Presentadas 3 opciones. Cliente evaluando. Reunión programada para 04/12.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-05', text: 'Seguimiento de propuesta de renovación con cliente', type: 'renewal', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-04-01', text: 'Carrier actualiza estado de reclamo Flia. Aquino', type: 'claim_update', actor: 'Seguros del Pacífico' },
|
||||
{ date: '2026-03-25', text: 'Propuesta de renovación enviada al cliente (3 opciones)', type: 'renewal', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-20', text: 'Censo actualizado recibido', type: 'document', actor: 'Celeste Riveros' },
|
||||
{ date: '2026-03-15', text: 'Reporte de siniestralidad recibido - 45%', type: 'document', actor: 'Seguros del Pacífico' },
|
||||
{ date: '2026-03-01', text: 'Solicitud de baja: Esteban Villalba por renuncia', type: 'exclusion', actor: 'Celeste Riveros' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 1,
|
||||
pendingTasks: 3,
|
||||
},
|
||||
|
||||
// ── 6. Tech Solutions S.A. ──
|
||||
{
|
||||
id: 'col-006',
|
||||
name: 'Tech Solutions S.A.',
|
||||
ruc: '80101234-2',
|
||||
lob: 'Health',
|
||||
product: 'Salud Digital Premium',
|
||||
carrier: 'Integral Medical',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Lic. Pamela Giménez',
|
||||
contactEmail: 'pgimenez@techsolutions.com.py',
|
||||
contactPhone: '+595 21 730-8800',
|
||||
hrContactName: 'Rodrigo Sanabria',
|
||||
hrContactEmail: 'rsanabria@techsolutions.com.py',
|
||||
|
||||
effectiveDate: '2025-11-01',
|
||||
renewalDate: '2026-11-01',
|
||||
onboardingDate: '2025-10-15',
|
||||
|
||||
totalMembers: 62,
|
||||
activeMembersCount: 60,
|
||||
dependentsCount: 85,
|
||||
pendingEnrollment: 2,
|
||||
|
||||
monthlyPremium: 3125,
|
||||
annualPremium: 37500,
|
||||
commissionPct: 10,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-006-01', name: 'Lic. Pamela Giménez', documentId: '1.616.161', email: 'pgimenez@techsolutions.com.py', phone: '+595 986 777-001', role: 'CEO', department: 'Dirección', enrollmentDate: '2025-11-01', status: 'active', tier: 'Executive', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-02', name: 'Rodrigo Sanabria', documentId: '2.626.262', email: 'rsanabria@techsolutions.com.py', phone: '+595 986 777-002', role: 'People & Culture Lead', department: 'People', enrollmentDate: '2025-11-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-03', name: 'Matías Recalde', documentId: '3.636.363', email: 'mrecalde@techsolutions.com.py', phone: '+595 986 777-003', role: 'CTO', department: 'Engineering', enrollmentDate: '2025-11-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-04', name: 'Sofía Cardozo', documentId: '4.646.464', email: 'scardozo@techsolutions.com.py', phone: '+595 986 777-004', role: 'UX Designer', department: 'Design', enrollmentDate: '2025-11-15', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-05', name: 'Alejandro Núñez', documentId: '5.656.565', email: 'anunez@techsolutions.com.py', phone: '+595 986 777-005', role: 'Full Stack Developer', department: 'Engineering', enrollmentDate: '2025-12-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-06', name: 'Valeria Ocampos', documentId: '6.666.676', email: 'vocampos@techsolutions.com.py', phone: '+595 986 777-006', role: 'QA Engineer', department: 'Engineering', enrollmentDate: '2026-01-15', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-07', name: 'Nicolás Franco', documentId: '7.676.767', email: 'nfranco@techsolutions.com.py', phone: '+595 986 777-007', role: 'DevOps Engineer', department: 'Engineering', enrollmentDate: '2026-03-01', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Formulario de inscripción'], formsCompleted: 2, formsTotal: 3 },
|
||||
{ id: 'mbr-006-08', name: 'Carolina Espínola', documentId: '8.686.868', email: 'cespinola@techsolutions.com.py', phone: '+595 986 777-008', role: 'Product Manager', department: 'Product', enrollmentDate: '2026-03-15', status: 'pending_enrollment', tier: 'Plus', dependents: 1, pendingDocs: ['Formulario de inscripción', 'Declaración de salud'], formsCompleted: 1, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-006-01', name: 'Póliza Salud Digital Premium 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-10-28', fileSize: '2.9 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-006-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-10-20', fileSize: '1.3 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-006-03', name: 'Censo Q1 2026', category: 'census', uploadedBy: 'Rodrigo Sanabria', uploadedAt: '2026-03-30', fileSize: '310 KB', fileType: 'XLSX', version: 1, notes: '2 nuevas altas pendientes' },
|
||||
{ id: 'doc-006-04', name: 'Endoso #2 - Inclusiones Ene 2026', category: 'endorsement', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2026-01-25', fileSize: '185 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-006-01', period: 'February 2026', dueDate: '2026-02-05', status: 'paid', invoiceAmount: 3050, paidAmount: 3050, carrierRef: 'IM-2026-062-02', membersBilled: 60, membersExpected: 60, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-006-02', period: 'March 2026', dueDate: '2026-03-05', status: 'paid', invoiceAmount: 3050, paidAmount: 3050, carrierRef: 'IM-2026-062-03', membersBilled: 60, membersExpected: 60, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-006-03', period: 'April 2026', dueDate: '2026-04-05', status: 'paid', invoiceAmount: 3125, paidAmount: 3125, carrierRef: 'IM-2026-062-04', membersBilled: 62, membersExpected: 62, discrepancy: 0, notes: 'Incluye 2 nuevos miembros pendientes de inscripción formal' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-006-01', type: 'inclusion', subject: 'Alta de 2 nuevos empleados - Marzo 2026', status: 'in_progress', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-03-10', updated: '2026-04-02', notes: 'Faltan formularios de inscripción. People & Culture dará seguimiento.' },
|
||||
{ id: 'sr-006-02', type: 'certificate', subject: 'Certificado para trámite de visa - S. Cardozo', status: 'resolved', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-03-18', updated: '2026-03-22', memberName: 'Sofía Cardozo', notes: 'Certificado emitido y enviado.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-05', text: 'Factura Abril pagada a tiempo', type: 'billing', actor: 'Rodrigo Sanabria' },
|
||||
{ date: '2026-04-02', text: 'Seguimiento de formularios pendientes para nuevas altas', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-30', text: 'Censo Q1 2026 cargado', type: 'document', actor: 'Rodrigo Sanabria' },
|
||||
{ date: '2026-03-22', text: 'Certificado de visa emitido para Sofía Cardozo', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-10', text: 'Solicitud de alta para 2 nuevos empleados', type: 'inclusion', actor: 'Rodrigo Sanabria' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
const KEY = 'policy-ui-colectivos-v1'
|
||||
|
||||
export function useColectivos() {
|
||||
const accounts = useLocalStorageRef<ColectivoAccount[]>(KEY, buildDefaultAccounts)
|
||||
|
||||
/* ── Lookups ── */
|
||||
|
||||
function getAccount(id: string): ColectivoAccount | undefined {
|
||||
return accounts.value.find(a => a.id === id)
|
||||
}
|
||||
|
||||
function getAccountMembers(accountId: string): ColectivoMember[] {
|
||||
return getAccount(accountId)?.members ?? []
|
||||
}
|
||||
|
||||
function getAccountServiceRequests(accountId: string): ServiceRequest[] {
|
||||
return getAccount(accountId)?.serviceRequests ?? []
|
||||
}
|
||||
|
||||
/* ── Filtered lists ── */
|
||||
|
||||
const activeAccounts = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'active'),
|
||||
)
|
||||
|
||||
const onboardingAccounts = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'onboarding'),
|
||||
)
|
||||
|
||||
/* ── Aggregate stats ── */
|
||||
|
||||
const totalMembers = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.totalMembers, 0),
|
||||
)
|
||||
|
||||
const totalDependents = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.dependentsCount, 0),
|
||||
)
|
||||
|
||||
const totalPremium = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.annualPremium, 0),
|
||||
)
|
||||
|
||||
const urgentIssuesCount = computed(() =>
|
||||
accounts.value.filter(a => a.hasUrgentIssues).length,
|
||||
)
|
||||
|
||||
return {
|
||||
accounts,
|
||||
getAccount,
|
||||
getAccountMembers,
|
||||
getAccountServiceRequests,
|
||||
activeAccounts,
|
||||
onboardingAccounts,
|
||||
totalMembers,
|
||||
totalDependents,
|
||||
totalPremium,
|
||||
urgentIssuesCount,
|
||||
}
|
||||
}
|
||||
228
app/composables/useCustomerAttention.ts
Normal file
228
app/composables/useCustomerAttention.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type ServiceTierId = string
|
||||
|
||||
export interface ServiceTier {
|
||||
id: ServiceTierId
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
description: string
|
||||
minScore: number
|
||||
benefits: string[]
|
||||
}
|
||||
|
||||
export interface AttentionRule {
|
||||
id: string
|
||||
field: 'premium' | 'policy_count' | 'commission' | 'collectivo_member' | 'multi_line' | 'tenure_years' | 'has_private_policies'
|
||||
operator: 'gte' | 'lte' | 'eq' | 'gt' | 'lt'
|
||||
value: number | boolean
|
||||
points: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CustomerAttentionConfig {
|
||||
tiers: ServiceTier[]
|
||||
rules: AttentionRule[]
|
||||
autoClassify: boolean
|
||||
}
|
||||
|
||||
/* ── Defaults ── */
|
||||
|
||||
function defaultTiers(): ServiceTier[] {
|
||||
return [
|
||||
{
|
||||
id: 'platinum',
|
||||
name: 'Platinum',
|
||||
color: '#7c3aed',
|
||||
icon: 'i-heroicons-star',
|
||||
description: 'VIP multi-line clients with highest lifetime value',
|
||||
minScore: 80,
|
||||
benefits: ['Priority claims handling', 'Dedicated account manager', 'Annual review', 'Renewal negotiation priority'],
|
||||
},
|
||||
{
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
color: '#d4a017',
|
||||
icon: 'i-heroicons-trophy',
|
||||
description: 'Established clients with strong portfolio',
|
||||
minScore: 55,
|
||||
benefits: ['Priority support', 'Proactive renewal outreach', 'Cross-sell consultation'],
|
||||
},
|
||||
{
|
||||
id: 'silver',
|
||||
name: 'Silver',
|
||||
color: '#6b7280',
|
||||
icon: 'i-heroicons-shield-check',
|
||||
description: 'Active clients with growth potential',
|
||||
minScore: 30,
|
||||
benefits: ['Standard support', 'Regular check-ins'],
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard',
|
||||
color: '#01696f',
|
||||
icon: 'i-heroicons-user',
|
||||
description: 'All active customers',
|
||||
minScore: 0,
|
||||
benefits: ['Standard service'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function defaultRules(): AttentionRule[] {
|
||||
return [
|
||||
{ id: 'r1', field: 'premium', operator: 'gte', value: 5000, points: 25, label: 'Annual premium >= $5,000' },
|
||||
{ id: 'r2', field: 'premium', operator: 'gte', value: 10000, points: 40, label: 'Annual premium >= $10,000' },
|
||||
{ id: 'r3', field: 'policy_count', operator: 'gte', value: 3, points: 15, label: '3+ active policies' },
|
||||
{ id: 'r4', field: 'policy_count', operator: 'gte', value: 5, points: 25, label: '5+ active policies' },
|
||||
{ id: 'r5', field: 'multi_line', operator: 'gte', value: 3, points: 20, label: 'Multi-line (3+ different lines)' },
|
||||
{ id: 'r6', field: 'tenure_years', operator: 'gte', value: 3, points: 10, label: 'Client for 3+ years' },
|
||||
{ id: 'r7', field: 'tenure_years', operator: 'gte', value: 5, points: 20, label: 'Client for 5+ years' },
|
||||
{ id: 'r8', field: 'collectivo_member', operator: 'eq', value: true, points: 15, label: 'Collectivo member with private policies' },
|
||||
{ id: 'r9', field: 'commission', operator: 'gte', value: 1000, points: 10, label: 'Annual commission >= $1,000' },
|
||||
]
|
||||
}
|
||||
|
||||
function defaultConfig(): CustomerAttentionConfig {
|
||||
return {
|
||||
tiers: defaultTiers(),
|
||||
rules: defaultRules(),
|
||||
autoClassify: true,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Customer input shape ── */
|
||||
|
||||
export interface CustomerAttentionInput {
|
||||
totalPremium: number
|
||||
policyCount: number
|
||||
lineCount: number
|
||||
tenureYears: number
|
||||
isCollectivoMember: boolean
|
||||
hasPrivatePolicies: boolean
|
||||
estimatedCommission: number
|
||||
}
|
||||
|
||||
/* ── Rule evaluation ── */
|
||||
|
||||
function evaluateRule(rule: AttentionRule, customer: CustomerAttentionInput): boolean {
|
||||
let fieldValue: number | boolean
|
||||
|
||||
switch (rule.field) {
|
||||
case 'premium':
|
||||
fieldValue = customer.totalPremium
|
||||
break
|
||||
case 'policy_count':
|
||||
fieldValue = customer.policyCount
|
||||
break
|
||||
case 'commission':
|
||||
fieldValue = customer.estimatedCommission
|
||||
break
|
||||
case 'multi_line':
|
||||
fieldValue = customer.lineCount
|
||||
break
|
||||
case 'tenure_years':
|
||||
fieldValue = customer.tenureYears
|
||||
break
|
||||
case 'collectivo_member':
|
||||
// Special: collectivo member rule only matches if also has private policies
|
||||
return rule.value === true && customer.isCollectivoMember && customer.hasPrivatePolicies
|
||||
case 'has_private_policies':
|
||||
return typeof rule.value === 'boolean' ? customer.hasPrivatePolicies === rule.value : false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof fieldValue === 'boolean' || typeof rule.value === 'boolean') return false
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'gte': return (fieldValue as number) >= (rule.value as number)
|
||||
case 'gt': return (fieldValue as number) > (rule.value as number)
|
||||
case 'lte': return (fieldValue as number) <= (rule.value as number)
|
||||
case 'lt': return (fieldValue as number) < (rule.value as number)
|
||||
case 'eq': return (fieldValue as number) === (rule.value as number)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
export function useCustomerAttention() {
|
||||
const config = useLocalStorageRef<CustomerAttentionConfig>('policy-ui-customer-attention-v1', defaultConfig)
|
||||
|
||||
const tiers = computed(() => [...config.value.tiers].sort((a, b) => b.minScore - a.minScore))
|
||||
|
||||
const rules = computed(() => config.value.rules)
|
||||
|
||||
function getScoreForCustomer(customer: CustomerAttentionInput): number {
|
||||
let score = 0
|
||||
for (const rule of config.value.rules) {
|
||||
if (evaluateRule(rule, customer)) {
|
||||
score += rule.points
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function getTierForCustomer(customer: CustomerAttentionInput): ServiceTier {
|
||||
const score = getScoreForCustomer(customer)
|
||||
const sorted = [...config.value.tiers].sort((a, b) => b.minScore - a.minScore)
|
||||
for (const tier of sorted) {
|
||||
if (score >= tier.minScore) return tier
|
||||
}
|
||||
// Fallback to lowest tier
|
||||
return sorted[sorted.length - 1] ?? config.value.tiers[0]
|
||||
}
|
||||
|
||||
/* ── Tier CRUD ── */
|
||||
|
||||
function addTier(tier: ServiceTier) {
|
||||
config.value.tiers.push(tier)
|
||||
}
|
||||
|
||||
function updateTier(id: string, patch: Partial<ServiceTier>) {
|
||||
const idx = config.value.tiers.findIndex(t => t.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.tiers[idx] = { ...config.value.tiers[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeTier(id: string) {
|
||||
config.value.tiers = config.value.tiers.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Rule CRUD ── */
|
||||
|
||||
function addRule(rule: AttentionRule) {
|
||||
config.value.rules.push(rule)
|
||||
}
|
||||
|
||||
function updateRule(id: string, patch: Partial<AttentionRule>) {
|
||||
const idx = config.value.rules.findIndex(r => r.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.rules[idx] = { ...config.value.rules[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(id: string) {
|
||||
config.value.rules = config.value.rules.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
tiers,
|
||||
rules,
|
||||
getScoreForCustomer,
|
||||
getTierForCustomer,
|
||||
addTier,
|
||||
updateTier,
|
||||
removeTier,
|
||||
addRule,
|
||||
updateRule,
|
||||
removeRule,
|
||||
}
|
||||
}
|
||||
18
app/composables/useCustomerProfileVault.ts
Normal file
18
app/composables/useCustomerProfileVault.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { emptyCustomerProfile, type CustomerProfileVault } from '~/types/customer-profile'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-customer-profile-vault-v1'
|
||||
|
||||
export function useCustomerProfileVault() {
|
||||
const profile = useLocalStorageRef(KEY, () => emptyCustomerProfile())
|
||||
|
||||
function touch() {
|
||||
profile.value.updatedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
profile.value = emptyCustomerProfile()
|
||||
}
|
||||
|
||||
return { profile, touch, reset }
|
||||
}
|
||||
273
app/composables/useDashboardHomeWidgets.ts
Normal file
273
app/composables/useDashboardHomeWidgets.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Home dashboard widget visibility — role presets + per-widget toggles.
|
||||
* Persisted locally until per-user API exists.
|
||||
*/
|
||||
|
||||
export type DashboardWidgetId =
|
||||
| 'hero'
|
||||
| 'milestone'
|
||||
| 'performance'
|
||||
| 'tasks_alerts'
|
||||
| 'charts'
|
||||
| 'brokerage_health'
|
||||
| 'quotes_line'
|
||||
| 'notes'
|
||||
| 'calendar'
|
||||
| 'quick_leads'
|
||||
| 'sales_leads'
|
||||
| 'client_favorites'
|
||||
| 'drafts'
|
||||
|
||||
export type DashboardRolePresetId =
|
||||
| 'sales_manager'
|
||||
| 'executive_manager'
|
||||
| 'director'
|
||||
| 'financial'
|
||||
| 'admin_manager'
|
||||
| 'customer_service_manager'
|
||||
|
||||
export type DashboardWidgetMeta = {
|
||||
id: DashboardWidgetId
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGETS: DashboardWidgetMeta[] = [
|
||||
{ id: 'hero', label: 'Welcome banner', description: 'Greeting, CTAs, workspace strip' },
|
||||
{ id: 'milestone', label: 'MTD milestone', description: 'Plan vs actual snapshot' },
|
||||
{ id: 'tasks_alerts', label: 'Tasks & alerts', description: 'Daily work + exceptions' },
|
||||
{ id: 'performance', label: 'Today at a glance', description: 'Headline KPIs + sparklines' },
|
||||
{ id: 'charts', label: 'Charts', description: 'GWP trend & quoted pipeline' },
|
||||
{ id: 'brokerage_health', label: 'Brokerage health', description: 'YTD / trailing book metrics' },
|
||||
{ id: 'quotes_line', label: 'Sent quotes', description: 'Sortable list of quotes sent to clients' },
|
||||
{ id: 'notes', label: 'Notes', description: 'Personal scratchpad and reminders' },
|
||||
{ id: 'calendar', label: 'Calendar', description: 'Agenda, renewals, alerts & reminders' },
|
||||
{ id: 'quick_leads', label: 'Quick leads', description: 'Recent quick leads from the last 10 days' },
|
||||
{ id: 'sales_leads', label: 'Sales leads', description: 'All leads by source — filter by channel, campaign, or API' },
|
||||
{ id: 'client_favorites', label: 'Favorite clients', description: 'Starred clients for quick access' },
|
||||
{ id: 'drafts', label: 'Drafts', description: 'Resume in-progress quotes, solicitudes & registrations' }
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'policy-ui.dashboard.widgets.v4'
|
||||
|
||||
export const DEFAULT_WIDGET_ORDER: DashboardWidgetId[] = DASHBOARD_WIDGETS.map((w) => w.id)
|
||||
|
||||
function normalizeWidgetOrder(raw: unknown): DashboardWidgetId[] {
|
||||
const base = [...DEFAULT_WIDGET_ORDER]
|
||||
if (!Array.isArray(raw)) return base
|
||||
const seen = new Set<DashboardWidgetId>()
|
||||
const out: DashboardWidgetId[] = []
|
||||
for (const x of raw) {
|
||||
if (typeof x === 'string' && base.includes(x as DashboardWidgetId) && !seen.has(x as DashboardWidgetId)) {
|
||||
const id = x as DashboardWidgetId
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
for (const id of base) {
|
||||
if (!seen.has(id)) out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const ALL_ON: Record<DashboardWidgetId, boolean> = {
|
||||
hero: true,
|
||||
milestone: true,
|
||||
performance: false,
|
||||
tasks_alerts: true,
|
||||
charts: true,
|
||||
brokerage_health: true,
|
||||
quotes_line: true,
|
||||
notes: true,
|
||||
calendar: true,
|
||||
quick_leads: true,
|
||||
sales_leads: true,
|
||||
client_favorites: true,
|
||||
drafts: true
|
||||
}
|
||||
|
||||
export const DASHBOARD_ROLE_PRESETS: Record<
|
||||
DashboardRolePresetId,
|
||||
{ label: string; hint: string; widgets: Record<DashboardWidgetId, boolean> }
|
||||
> = {
|
||||
sales_manager: {
|
||||
label: 'Sales manager',
|
||||
hint: 'Pipeline, tasks, quotes — lighter book-of-business tile.',
|
||||
widgets: { ...ALL_ON, brokerage_health: false }
|
||||
},
|
||||
executive_manager: {
|
||||
label: 'Executive manager',
|
||||
hint: 'Balanced operational + book view.',
|
||||
widgets: { ...ALL_ON }
|
||||
},
|
||||
director: {
|
||||
label: 'Director',
|
||||
hint: 'Strategic KPIs & health; fewer operational tiles.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
tasks_alerts: false,
|
||||
quotes_line: false
|
||||
}
|
||||
},
|
||||
financial: {
|
||||
label: 'Financial',
|
||||
hint: 'Premium, AR, health metrics; fewer sales shortcuts.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
quotes_line: false,
|
||||
tasks_alerts: true,
|
||||
performance: false,
|
||||
brokerage_health: true,
|
||||
charts: true,
|
||||
sales_leads: false
|
||||
}
|
||||
},
|
||||
admin_manager: {
|
||||
label: 'Admin / operations',
|
||||
hint: 'Permissions, forms, and carrier setup — fewer quote shortcuts.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
quotes_line: false,
|
||||
charts: false,
|
||||
brokerage_health: true,
|
||||
sales_leads: false
|
||||
}
|
||||
},
|
||||
customer_service_manager: {
|
||||
label: 'Customer service manager',
|
||||
hint: 'Queues, tasks, and exceptions — lighter GWP / book tiles.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
charts: false,
|
||||
brokerage_health: false,
|
||||
quotes_line: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable order for selects. */
|
||||
export const DASHBOARD_PRESET_ORDER: DashboardRolePresetId[] = [
|
||||
'sales_manager',
|
||||
'executive_manager',
|
||||
'director',
|
||||
'financial',
|
||||
'admin_manager',
|
||||
'customer_service_manager'
|
||||
]
|
||||
|
||||
function cloneWidgets(w: Record<DashboardWidgetId, boolean>): Record<DashboardWidgetId, boolean> {
|
||||
return { ...w }
|
||||
}
|
||||
|
||||
export function useDashboardHomeWidgets() {
|
||||
const activePreset = ref<DashboardRolePresetId>('executive_manager')
|
||||
const widgets = ref<Record<DashboardWidgetId, boolean>>(
|
||||
cloneWidgets(DASHBOARD_ROLE_PRESETS.executive_manager.widgets)
|
||||
)
|
||||
const widgetOrder = ref<DashboardWidgetId[]>([...DEFAULT_WIDGET_ORDER])
|
||||
const layoutUnlocked = ref(false)
|
||||
const hydrated = ref(false)
|
||||
|
||||
function persist() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
preset: activePreset.value,
|
||||
widgets: widgets.value,
|
||||
widgetOrder: widgetOrder.value,
|
||||
layoutUnlocked: layoutUnlocked.value
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const data = JSON.parse(raw) as {
|
||||
preset?: DashboardRolePresetId
|
||||
widgets?: Partial<Record<DashboardWidgetId, boolean>>
|
||||
widgetOrder?: DashboardWidgetId[]
|
||||
layoutUnlocked?: boolean
|
||||
}
|
||||
if (data.preset && DASHBOARD_ROLE_PRESETS[data.preset]) {
|
||||
activePreset.value = data.preset
|
||||
}
|
||||
if (data.widgets) {
|
||||
const merged = { ...widgets.value }
|
||||
for (const k of Object.keys(merged) as DashboardWidgetId[]) {
|
||||
if (data.widgets[k] !== undefined) merged[k] = data.widgets[k]!
|
||||
}
|
||||
widgets.value = merged
|
||||
}
|
||||
widgetOrder.value = normalizeWidgetOrder(data.widgetOrder)
|
||||
if (typeof data.layoutUnlocked === 'boolean') {
|
||||
layoutUnlocked.value = data.layoutUnlocked
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
hydrated.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
[activePreset, widgets, widgetOrder, layoutUnlocked],
|
||||
() => {
|
||||
if (hydrated.value) persist()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const isPresetDirty = computed(() => {
|
||||
const preset = DASHBOARD_ROLE_PRESETS[activePreset.value]
|
||||
if (!preset) return false
|
||||
return DASHBOARD_WIDGETS.some((w) => widgets.value[w.id] !== preset.widgets[w.id])
|
||||
})
|
||||
|
||||
function applyPreset(id: DashboardRolePresetId) {
|
||||
activePreset.value = id
|
||||
const p = DASHBOARD_ROLE_PRESETS[id]
|
||||
if (p) widgets.value = cloneWidgets(p.widgets)
|
||||
}
|
||||
|
||||
function setWidget(id: DashboardWidgetId, on: boolean) {
|
||||
widgets.value = { ...widgets.value, [id]: on }
|
||||
}
|
||||
|
||||
function reapplySelectedPreset() {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
function reorderWidgets(fromId: DashboardWidgetId, toId: DashboardWidgetId) {
|
||||
if (fromId === toId) return
|
||||
const arr = [...widgetOrder.value]
|
||||
const fromI = arr.indexOf(fromId)
|
||||
const toI = arr.indexOf(toId)
|
||||
if (fromI === -1 || toI === -1) return
|
||||
arr.splice(fromI, 1)
|
||||
arr.splice(toI, 0, fromId)
|
||||
widgetOrder.value = arr
|
||||
}
|
||||
|
||||
return {
|
||||
widgets,
|
||||
widgetOrder,
|
||||
layoutUnlocked,
|
||||
activePreset,
|
||||
isPresetDirty,
|
||||
applyPreset,
|
||||
setWidget,
|
||||
reapplySelectedPreset,
|
||||
reorderWidgets
|
||||
}
|
||||
}
|
||||
52
app/composables/useEmissionsQueue.ts
Normal file
52
app/composables/useEmissionsQueue.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export type EmissionItem = {
|
||||
id: string
|
||||
createdAt: string
|
||||
customerLabel: string
|
||||
insurerSlug: string
|
||||
subRamoKey: string
|
||||
productLine: string
|
||||
status: 'pending_review' | 'approved' | 'sent_to_insurer' | 'in_force'
|
||||
bindToken?: string
|
||||
/** 'auto' = generated from quote acceptance, 'manual' = created from solicitud form */
|
||||
source?: 'auto' | 'manual'
|
||||
/** Carrier product name when auto-generated from comparative */
|
||||
carrierProduct?: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-emissions-queue-v1'
|
||||
|
||||
export function useEmissionsQueue() {
|
||||
const items = useLocalStorageRef<EmissionItem[]>(KEY, () => [])
|
||||
|
||||
function enqueue(
|
||||
entry: Omit<EmissionItem, 'id' | 'createdAt' | 'status'> & { status?: EmissionItem['status'] }
|
||||
) {
|
||||
const row: EmissionItem = {
|
||||
id: crypto.randomUUID?.() ?? String(Date.now()),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: entry.status ?? 'pending_review',
|
||||
...entry
|
||||
}
|
||||
items.value = [row, ...items.value]
|
||||
return row
|
||||
}
|
||||
|
||||
function approve(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'approved'
|
||||
}
|
||||
|
||||
function sendToInsurer(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'sent_to_insurer'
|
||||
}
|
||||
|
||||
function markInForce(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'in_force'
|
||||
}
|
||||
|
||||
return { items, enqueue, approve, sendToInsurer, markInForce }
|
||||
}
|
||||
174
app/composables/useFormsCatalog.ts
Normal file
174
app/composables/useFormsCatalog.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import catalogJson from '~/data/forms-catalog.json'
|
||||
import fieldGroupsJson from '~/data/form-field-groups.json'
|
||||
import type {
|
||||
FormCatalogFile,
|
||||
FormCatalogProductLine,
|
||||
FormCatalogRow,
|
||||
FormCatalogSelection
|
||||
} from '~/types/form-catalog'
|
||||
import type { FormFieldGroupDef, FormFieldGroupsFile } from '~/types/form-field-groups'
|
||||
|
||||
const catalog = catalogJson as FormCatalogFile
|
||||
const fieldGroupsFile = fieldGroupsJson as FormFieldGroupsFile
|
||||
|
||||
const PRODUCT_LINE_LABELS: Record<FormCatalogProductLine, string> = {
|
||||
life: 'Life',
|
||||
health_local: 'Health · local',
|
||||
health_international: 'Health · international',
|
||||
auto_full_coverage: 'Auto · full coverage',
|
||||
auto_dat_liability: 'Auto · DAT (liability)',
|
||||
home: 'Home',
|
||||
general_liability: 'General liability',
|
||||
any: 'Any / not specified'
|
||||
}
|
||||
|
||||
const ALL_PRODUCT_LINES: FormCatalogProductLine[] = [
|
||||
'life',
|
||||
'health_local',
|
||||
'health_international',
|
||||
'auto_full_coverage',
|
||||
'auto_dat_liability',
|
||||
'home',
|
||||
'general_liability',
|
||||
'any'
|
||||
]
|
||||
|
||||
export function productLineLabel(line: FormCatalogProductLine | null | undefined): string {
|
||||
if (line == null || line === 'any') return '—'
|
||||
return PRODUCT_LINE_LABELS[line] ?? String(line)
|
||||
}
|
||||
|
||||
function personMatches(row: FormCatalogRow, person: 'natural' | 'juridica'): boolean {
|
||||
if (row.personKinds === 'both') return true
|
||||
return row.personKinds === person
|
||||
}
|
||||
|
||||
function productLineMatches(row: FormCatalogRow, sel: FormCatalogSelection): boolean {
|
||||
const rowPl = row.productLine
|
||||
const selPl = sel.productLine
|
||||
|
||||
if (selPl === null || selPl === 'any') {
|
||||
return rowPl == null
|
||||
}
|
||||
if (rowPl == null) return true
|
||||
return rowPl === selPl
|
||||
}
|
||||
|
||||
function subRamoMatches(row: FormCatalogRow, subRamoKey: string | null): boolean {
|
||||
if (!subRamoKey) return false
|
||||
if (row.subRamoKey === 'any') return true
|
||||
return row.subRamoKey === subRamoKey
|
||||
}
|
||||
|
||||
export function filterRows(all: FormCatalogRow[], sel: FormCatalogSelection): FormCatalogRow[] {
|
||||
if (!sel.insurerSlug || !sel.subRamoKey) return []
|
||||
return all.filter((row) => {
|
||||
if (!row.insurerSlugs.includes(sel.insurerSlug!)) return false
|
||||
if (!subRamoMatches(row, sel.subRamoKey)) return false
|
||||
if (!personMatches(row, sel.personKind)) return false
|
||||
if (!productLineMatches(row, sel)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveFieldGroupsForRows(
|
||||
matched: FormCatalogRow[],
|
||||
groupMap: Map<string, FormFieldGroupDef>
|
||||
): FormFieldGroupDef[] {
|
||||
const ids = new Set<string>()
|
||||
for (const r of matched) {
|
||||
for (const id of r.fieldGroupIds ?? []) ids.add(id)
|
||||
}
|
||||
return [...ids]
|
||||
.map((id) => groupMap.get(id))
|
||||
.filter((g): g is FormFieldGroupDef => g != null)
|
||||
}
|
||||
|
||||
export function buildFormMapIndex(rows: FormCatalogRow[]): Map<string, number[]> {
|
||||
const m = new Map<string, number[]>()
|
||||
for (const r of rows) {
|
||||
for (const ins of r.insurerSlugs) {
|
||||
const pl = r.productLine ?? ''
|
||||
const key = `${ins}|${r.subRamoKey}|${pl}`
|
||||
const list = m.get(key) ?? []
|
||||
list.push(r.id)
|
||||
m.set(key, list)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
function insurerSlugToLabel(slug: string): string {
|
||||
return slug
|
||||
.split('_')
|
||||
.map((w) => w.slice(0, 1).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildInsurerItems(rows: FormCatalogRow[]) {
|
||||
const set = new Set<string>()
|
||||
for (const r of rows) {
|
||||
for (const s of r.insurerSlugs) set.add(s)
|
||||
}
|
||||
return [...set]
|
||||
.sort()
|
||||
.map((value) => ({ label: insurerSlugToLabel(value), value }))
|
||||
}
|
||||
|
||||
function buildSubRamoItems(rows: FormCatalogRow[], insurerSlug: string | null) {
|
||||
if (!insurerSlug) return []
|
||||
const map = new Map<string, string>()
|
||||
for (const r of rows) {
|
||||
if (!r.insurerSlugs.includes(insurerSlug)) continue
|
||||
if (r.subRamoKey === 'any') continue
|
||||
map.set(r.subRamoKey, r.subRamoLabel)
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([value, label]) => ({ label, value }))
|
||||
}
|
||||
|
||||
function productLineSelectOptions() {
|
||||
return ALL_PRODUCT_LINES.map((value) => ({
|
||||
label: PRODUCT_LINE_LABELS[value],
|
||||
value
|
||||
}))
|
||||
}
|
||||
|
||||
export function useFormsCatalog() {
|
||||
const rows = computed(() => catalog.rows)
|
||||
const version = computed(() => catalog.version)
|
||||
|
||||
const groupById = computed(() => {
|
||||
const m = new Map<string, FormFieldGroupDef>()
|
||||
for (const g of fieldGroupsFile.groups) m.set(g.id, g)
|
||||
return m
|
||||
})
|
||||
|
||||
const insurerItems = computed(() => buildInsurerItems(catalog.rows))
|
||||
|
||||
function subRamoItems(insurerSlug: string | null) {
|
||||
return buildSubRamoItems(catalog.rows, insurerSlug)
|
||||
}
|
||||
|
||||
const productLineItems = productLineSelectOptions()
|
||||
|
||||
function fieldGroupsForMatched(matched: FormCatalogRow[]) {
|
||||
return resolveFieldGroupsForRows(matched, groupById.value)
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
rows,
|
||||
version,
|
||||
fieldGroupsVersion: computed(() => fieldGroupsFile.version),
|
||||
filterRows: (sel: FormCatalogSelection) => filterRows(catalog.rows, sel),
|
||||
fieldGroupsForMatched,
|
||||
buildFormMapIndex: () => buildFormMapIndex(catalog.rows),
|
||||
insurerItems,
|
||||
subRamoItems,
|
||||
productLineItems,
|
||||
productLineLabel,
|
||||
insurerSlugToLabel
|
||||
}
|
||||
}
|
||||
33
app/composables/useHealthQuoteDraft.ts
Normal file
33
app/composables/useHealthQuoteDraft.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { HealthQuoteDraft } from '~/types/health-quote-intake'
|
||||
|
||||
export function emptyHealthQuoteDraft(): HealthQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
client: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
documentId: '',
|
||||
organizationName: ''
|
||||
},
|
||||
health: {
|
||||
coverageArea: '',
|
||||
networkTier: '',
|
||||
deductible: '',
|
||||
dateOfBirth: '',
|
||||
age: '',
|
||||
preexistingConditions: false,
|
||||
preexistingDetails: ''
|
||||
},
|
||||
forms: {
|
||||
medicalQuestionnaire: false,
|
||||
beneficiaryDesignation: false,
|
||||
groupCensus: false
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/composables/useLifeQuoteDraft.ts
Normal file
36
app/composables/useLifeQuoteDraft.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { LifeQuoteDraft } from '~/types/life-quote-intake'
|
||||
|
||||
export function emptyLifeQuoteDraft(): LifeQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
client: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
documentId: '',
|
||||
organizationName: ''
|
||||
},
|
||||
life: {
|
||||
dateOfBirth: '',
|
||||
age: '',
|
||||
gender: '',
|
||||
smoker: false,
|
||||
preexistingConditions: false,
|
||||
preexistingDetails: '',
|
||||
coverageAmount: '',
|
||||
coverageTerm: '',
|
||||
beneficiaryName: '',
|
||||
beneficiaryRelationship: ''
|
||||
},
|
||||
forms: {
|
||||
medicalQuestionnaire: false,
|
||||
beneficiaryDesignation: false,
|
||||
groupCensus: false
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/composables/usePageTitle.ts
Normal file
6
app/composables/usePageTitle.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function usePageTitle(title: string) {
|
||||
useHead({
|
||||
title,
|
||||
titleTemplate: (t) => (t ? `${t} · Policy UI` : 'Policy UI')
|
||||
})
|
||||
}
|
||||
12
app/composables/usePdfFieldMappings.ts
Normal file
12
app/composables/usePdfFieldMappings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PdfFieldMappingFile } from '~/types/pdf-field-mapping'
|
||||
import mappingsJson from '~/data/pdf-field-mappings.json'
|
||||
|
||||
const file = mappingsJson as PdfFieldMappingFile
|
||||
|
||||
export function usePdfFieldMappings() {
|
||||
function mappingForCatalogFormId(catalogFormId: number) {
|
||||
return file.mappings.find((m) => m.catalogFormId === catalogFormId) ?? null
|
||||
}
|
||||
|
||||
return { version: file.version, mappingForCatalogFormId, mappings: file.mappings }
|
||||
}
|
||||
88
app/composables/usePolicyRegistrationModel.ts
Normal file
88
app/composables/usePolicyRegistrationModel.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { PolicyInstallmentRow, PolicyRegistration } from '~/types/brokerage-registration'
|
||||
import { POLICY_DRAFT_STORAGE_KEY } from '~/types/brokerage-registration'
|
||||
|
||||
export function createEmptyPolicyRegistration(): PolicyRegistration {
|
||||
return {
|
||||
mintPolicyNumber: '',
|
||||
contratanteId: '',
|
||||
ramo: '',
|
||||
subRamo: '',
|
||||
aseguradora: '',
|
||||
producto: '',
|
||||
agencia: '',
|
||||
numeroPolizaProveedor: '',
|
||||
acreedor: '',
|
||||
fechaEmision: '',
|
||||
inicioVigencia: '',
|
||||
finVigencia: '',
|
||||
comisiones: [
|
||||
{ idx: 1, agenteId: '', porcentaje: '' },
|
||||
{ idx: 2, agenteId: '', porcentaje: '' },
|
||||
{ idx: 3, agenteId: '', porcentaje: '' }
|
||||
],
|
||||
formaPago: '',
|
||||
valorAsegurado: '',
|
||||
primaBruta: '',
|
||||
impuestoPct: '6',
|
||||
primaNeta: '',
|
||||
numCuotas: 10,
|
||||
cuotas: [],
|
||||
cotizacionMintId: '',
|
||||
pdfCotizacionNombre: '',
|
||||
pdfPolizaNombre: '',
|
||||
notas: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildInstallmentSchedule(p: PolicyRegistration): PolicyInstallmentRow[] {
|
||||
const n = Math.max(1, Math.min(60, Math.floor(p.numCuotas) || 1))
|
||||
const start = p.inicioVigencia ? new Date(p.inicioVigencia) : new Date()
|
||||
const base = Number.isNaN(start.getTime()) ? new Date() : start
|
||||
const per = p.primaBruta
|
||||
? (Number.parseFloat(String(p.primaBruta).replace(/[^0-9.-]/g, '')) || 0) / n
|
||||
: 0
|
||||
const rows: PolicyInstallmentRow[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(base)
|
||||
d.setMonth(d.getMonth() + i)
|
||||
rows.push({
|
||||
n: i + 1,
|
||||
fechaVencimiento: d.toISOString().slice(0, 16),
|
||||
prima: per > 0 ? per.toFixed(2) : ''
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function setFinOneYearAfterInicio(p: PolicyRegistration) {
|
||||
if (!p.inicioVigencia) return
|
||||
const d = new Date(p.inicioVigencia)
|
||||
if (Number.isNaN(d.getTime())) return
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
p.finVigencia = d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function usePolicyDraftPersistence(form: Ref<PolicyRegistration>) {
|
||||
if (import.meta.server) return
|
||||
try {
|
||||
const raw = localStorage.getItem(POLICY_DRAFT_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as PolicyRegistration
|
||||
form.value = { ...createEmptyPolicyRegistration(), ...parsed, cuotas: parsed.cuotas ?? [] }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
watch(
|
||||
form,
|
||||
(v) => {
|
||||
try {
|
||||
localStorage.setItem(POLICY_DRAFT_STORAGE_KEY, JSON.stringify(v))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
205
app/composables/useProfileLayouts.ts
Normal file
205
app/composables/useProfileLayouts.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type ProfileRole = 'sales' | 'claims' | 'renewals' | 'general_service' | 'management' | 'superadmin'
|
||||
|
||||
export interface ProfileSection {
|
||||
id: string
|
||||
label: string
|
||||
visible: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface ProfileLayout {
|
||||
id: string
|
||||
role: ProfileRole | string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
sections: ProfileSection[]
|
||||
defaultTab: 'policies' | 'claims' | 'payments' | 'activity' | 'history' | 'relationships' | 'notes'
|
||||
isCustom: boolean
|
||||
}
|
||||
|
||||
/* ── Section catalog ── */
|
||||
|
||||
const ALL_SECTION_IDS = [
|
||||
'orientation',
|
||||
'kpi_strip',
|
||||
'quick_policies',
|
||||
'service_actions',
|
||||
'personal_details',
|
||||
'tabbed_content',
|
||||
'documents',
|
||||
] as const
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
orientation: 'Account Orientation',
|
||||
kpi_strip: 'KPI Strip',
|
||||
quick_policies: 'Quick Policies',
|
||||
service_actions: 'Service Actions',
|
||||
personal_details: 'Personal Details',
|
||||
tabbed_content: 'Tabbed Content',
|
||||
documents: 'Documents',
|
||||
}
|
||||
|
||||
function makeSections(order: string[], hidden: string[] = []): ProfileSection[] {
|
||||
return order.map((id, i) => ({
|
||||
id,
|
||||
label: SECTION_LABELS[id] ?? id,
|
||||
visible: !hidden.includes(id),
|
||||
order: i,
|
||||
}))
|
||||
}
|
||||
|
||||
/* ── Built-in layouts ── */
|
||||
|
||||
function defaultLayouts(): ProfileLayout[] {
|
||||
return [
|
||||
{
|
||||
id: 'sales',
|
||||
role: 'sales',
|
||||
name: 'Sales',
|
||||
description: 'Focus on policies, quotes, and pipeline.',
|
||||
icon: 'i-heroicons-currency-dollar',
|
||||
sections: makeSections([
|
||||
'orientation', 'quick_policies', 'kpi_strip', 'tabbed_content',
|
||||
'service_actions', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'claims',
|
||||
role: 'claims',
|
||||
name: 'Claims',
|
||||
description: 'Focus on claims and service actions.',
|
||||
icon: 'i-heroicons-shield-exclamation',
|
||||
sections: makeSections([
|
||||
'service_actions', 'orientation', 'kpi_strip', 'tabbed_content',
|
||||
'quick_policies', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'claims',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'renewals',
|
||||
role: 'renewals',
|
||||
name: 'Renewals',
|
||||
description: 'Focus on upcoming events and policies.',
|
||||
icon: 'i-heroicons-arrow-path',
|
||||
sections: makeSections([
|
||||
'orientation', 'quick_policies', 'kpi_strip', 'tabbed_content',
|
||||
'service_actions', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'general_service',
|
||||
role: 'general_service',
|
||||
name: 'General Service',
|
||||
description: 'Balanced default for service representatives.',
|
||||
icon: 'i-heroicons-lifebuoy',
|
||||
sections: makeSections([
|
||||
'orientation', 'kpi_strip', 'quick_policies', 'service_actions',
|
||||
'personal_details', 'tabbed_content', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'management',
|
||||
role: 'management',
|
||||
name: 'Management',
|
||||
description: 'KPIs first, everything visible.',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
sections: makeSections([
|
||||
'kpi_strip', 'orientation', 'service_actions', 'quick_policies',
|
||||
'tabbed_content', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'history',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'superadmin',
|
||||
role: 'superadmin',
|
||||
name: 'Superadmin',
|
||||
description: 'Everything visible, history focus.',
|
||||
icon: 'i-heroicons-cog-8-tooth',
|
||||
sections: makeSections([
|
||||
'kpi_strip', 'orientation', 'service_actions', 'quick_policies',
|
||||
'tabbed_content', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'history',
|
||||
isCustom: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const LAYOUTS_KEY = 'policy-ui-profile-layouts-v1'
|
||||
const ACTIVE_KEY = 'policy-ui-active-profile-layout-v1'
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
export function useProfileLayouts() {
|
||||
const layouts = useLocalStorageRef<ProfileLayout[]>(LAYOUTS_KEY, defaultLayouts)
|
||||
|
||||
const activeLayoutId = useLocalStorageRef<{ id: string }>(ACTIVE_KEY, () => ({ id: 'general_service' }))
|
||||
|
||||
const activeLayout = computed<ProfileLayout>(() => {
|
||||
const found = layouts.value.find(l => l.id === activeLayoutId.value.id)
|
||||
return found ?? layouts.value[0] ?? defaultLayouts()[3] // fallback to general_service
|
||||
})
|
||||
|
||||
const sortedSections = computed<ProfileSection[]>(() =>
|
||||
[...activeLayout.value.sections]
|
||||
.filter(s => s.visible)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
)
|
||||
|
||||
function setActiveLayout(id: string) {
|
||||
activeLayoutId.value = { id }
|
||||
}
|
||||
|
||||
function addCustomLayout(layout: Omit<ProfileLayout, 'isCustom'>) {
|
||||
layouts.value = [
|
||||
...layouts.value,
|
||||
{ ...layout, isCustom: true },
|
||||
]
|
||||
}
|
||||
|
||||
function updateLayout(id: string, partial: Partial<ProfileLayout>) {
|
||||
layouts.value = layouts.value.map(l =>
|
||||
l.id === id ? { ...l, ...partial } : l
|
||||
)
|
||||
}
|
||||
|
||||
function removeCustomLayout(id: string) {
|
||||
const target = layouts.value.find(l => l.id === id)
|
||||
if (!target || !target.isCustom) return
|
||||
layouts.value = layouts.value.filter(l => l.id !== id)
|
||||
if (activeLayoutId.value.id === id) {
|
||||
activeLayoutId.value = { id: 'general_service' }
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
layouts.value = defaultLayouts()
|
||||
activeLayoutId.value = { id: 'general_service' }
|
||||
}
|
||||
|
||||
return {
|
||||
layouts,
|
||||
activeLayoutId: computed(() => activeLayoutId.value.id),
|
||||
activeLayout,
|
||||
sortedSections,
|
||||
setActiveLayout,
|
||||
addCustomLayout,
|
||||
updateLayout,
|
||||
removeCustomLayout,
|
||||
resetToDefaults,
|
||||
}
|
||||
}
|
||||
26
app/composables/useProviderContactEmails.ts
Normal file
26
app/composables/useProviderContactEmails.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
emptyProviderContacts,
|
||||
PROVIDER_EMAIL_ROLE_LABEL,
|
||||
PROVIDER_EMAIL_ROLE_ORDER,
|
||||
type ProviderContactEmails,
|
||||
type ProviderEmailRole
|
||||
} from '~/types/provider-contacts'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
function storageKey(providerId: string) {
|
||||
return `policy-ui-provider-contacts-v1-${providerId}`
|
||||
}
|
||||
|
||||
export function useProviderContactEmails(providerId: string) {
|
||||
const emails = useLocalStorageRef(storageKey(providerId), emptyProviderContacts)
|
||||
|
||||
function label(r: ProviderEmailRole) {
|
||||
return PROVIDER_EMAIL_ROLE_LABEL[r]
|
||||
}
|
||||
|
||||
return {
|
||||
emails,
|
||||
roles: PROVIDER_EMAIL_ROLE_ORDER,
|
||||
label
|
||||
}
|
||||
}
|
||||
46
app/composables/useQuickLeads.ts
Normal file
46
app/composables/useQuickLeads.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Quick lead capture list — persisted in localStorage.
|
||||
* Used by the Quick Lead form and dashboard widget.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export interface QuickLead {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
product: string
|
||||
source: string
|
||||
priority: 'normal' | 'high' | 'urgent'
|
||||
note: string
|
||||
agent: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-quick-leads-v1'
|
||||
|
||||
export function useQuickLeads() {
|
||||
const leads = useLocalStorageRef<QuickLead[]>(KEY, () => [])
|
||||
|
||||
function addLead(entry: Omit<QuickLead, 'id' | 'createdAt'>) {
|
||||
const lead: QuickLead = {
|
||||
id: crypto.randomUUID?.() ?? String(Date.now()),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry,
|
||||
}
|
||||
leads.value = [lead, ...leads.value]
|
||||
return lead
|
||||
}
|
||||
|
||||
function removeLead(id: string) {
|
||||
leads.value = leads.value.filter((l) => l.id !== id)
|
||||
}
|
||||
|
||||
/** Leads from the last N days */
|
||||
function recentLeads(days: number) {
|
||||
const cutoff = Date.now() - days * 86_400_000
|
||||
return leads.value.filter((l) => new Date(l.createdAt).getTime() >= cutoff)
|
||||
}
|
||||
|
||||
return { leads, addLead, removeLead, recentLeads }
|
||||
}
|
||||
37
app/composables/useQuoteRequestEmailEnabled.ts
Normal file
37
app/composables/useQuoteRequestEmailEnabled.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const STORAGE_KEY = 'policy-ui.quote-request-email.v1'
|
||||
|
||||
/**
|
||||
* When false, quote flows record the intent locally but do not describe outbound provider emails
|
||||
* (use when rates come from in-app tables, AI, or carrier APIs instead).
|
||||
*/
|
||||
export function useQuoteRequestEmailEnabled() {
|
||||
const quoteRequestEmailEnabled = ref(true)
|
||||
|
||||
function read() {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === '0') quoteRequestEmailEnabled.value = false
|
||||
else if (v === '1') quoteRequestEmailEnabled.value = true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function setQuoteRequestEmailEnabled(v: boolean) {
|
||||
quoteRequestEmailEnabled.value = v
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => read())
|
||||
|
||||
return {
|
||||
quoteRequestEmailEnabled,
|
||||
setQuoteRequestEmailEnabled
|
||||
}
|
||||
}
|
||||
77
app/composables/useQuoteSession.ts
Normal file
77
app/composables/useQuoteSession.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { QuoteComparativeView } from '~/types/quote-view-model'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-quote-session-v1'
|
||||
|
||||
function defaultView(): QuoteComparativeView {
|
||||
return {
|
||||
title: 'ANÁLISIS COMPARATIVO · VIDA UNIVERSAL',
|
||||
subtitle: 'Protección & Ahorro',
|
||||
tagline:
|
||||
'Comparativa de aseguradoras — valores garantizados y proyectados. Prima mensual fija de referencia.',
|
||||
quoteDateIso: new Date().toISOString().slice(0, 10),
|
||||
validDays: 30,
|
||||
client: {
|
||||
name: 'María Claudia Piña Ríos',
|
||||
ageYears: 30,
|
||||
gender: 'Femenino',
|
||||
smoker: false,
|
||||
riskClass: 'Estándar',
|
||||
occupation: 'Administrativo'
|
||||
},
|
||||
request: {
|
||||
sumAssuredUsd: 100_000,
|
||||
monthlyPremiumUsd: 75,
|
||||
annualPremiumUsd: 900,
|
||||
benefitTypeLabel: 'Opción B — Creciente',
|
||||
additionalCoverageLabel: 'No contratadas',
|
||||
initialDepositLabel: 'No aplica'
|
||||
},
|
||||
carriers: [
|
||||
{
|
||||
carrierName: 'ASSA COMPAÑÍA DE SEGUROS, S.A.',
|
||||
productName: 'ASSA Universal II',
|
||||
ratesLine: 'T. garantizada 3.5% · T. corriente 4.0%',
|
||||
sumAssuredUsd: 100_000,
|
||||
footnote: 'Vigente hasta edad 91',
|
||||
cells: [
|
||||
{ yearLabel: 'Año 10', ageLabel: '40', guaranteed: 8200, projected: 12400 },
|
||||
{ yearLabel: 'Año 20', ageLabel: '50', guaranteed: 15200, projected: 24100 },
|
||||
{ yearLabel: 'Año 30', ageLabel: '60', guaranteed: 21000, projected: 38900 },
|
||||
{ yearLabel: 'Edad 65', ageLabel: '65', guaranteed: 24500, projected: 36859 }
|
||||
],
|
||||
highlightProjectedUsd: 36859,
|
||||
highlightNote: 'Valor proyectado a la edad de referencia'
|
||||
},
|
||||
{
|
||||
carrierName: 'ASSA COMPAÑÍA DE SEGUROS, S.A.',
|
||||
productName: 'ASSA Vida Segura',
|
||||
ratesLine: 'T. garantizada 4.0% · T. corriente 4.0%',
|
||||
sumAssuredUsd: 100_000,
|
||||
footnote: 'Vence a los 70 años',
|
||||
cells: [
|
||||
{ yearLabel: 'Año 10', ageLabel: '40', guaranteed: 7800, projected: 11800 },
|
||||
{ yearLabel: 'Año 20', ageLabel: '50', guaranteed: 14100, projected: 22800 },
|
||||
{ yearLabel: 'Año 30', ageLabel: '60', guaranteed: 19800, projected: 35200 },
|
||||
{ yearLabel: 'Edad 65', ageLabel: '65', guaranteed: 22100, projected: 33100 }
|
||||
],
|
||||
highlightProjectedUsd: 33100,
|
||||
highlightNote: 'Revisar vigencia del producto'
|
||||
}
|
||||
],
|
||||
accumulatedPremiumsUsd: [9000, 18_000, 27_000, 31_500],
|
||||
advisorColumns: [
|
||||
'Retorno garantizado a los 65: comparar valores acumulados vs primas pagadas.',
|
||||
'Protección de largo plazo: priorizar vigencia del seguro hasta edad avanzada.',
|
||||
'Coberturas opcionales (cáncer, cardiovascular, etc.) no incluidas en esta cotización.'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuoteSession() {
|
||||
const view = useLocalStorageRef(KEY, defaultView)
|
||||
function reset() {
|
||||
view.value = defaultView()
|
||||
}
|
||||
return { view, reset, defaultView }
|
||||
}
|
||||
67
app/composables/useReferralChannels.ts
Normal file
67
app/composables/useReferralChannels.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Referral channel registry — persisted in localStorage.
|
||||
* Used across the app: quick leads, customer registration, reporting.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export interface ReferralChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'person' | 'company' | 'digital' | 'event' | 'other'
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
note: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-referral-channels-v1'
|
||||
|
||||
const SEED_CHANNELS: ReferralChannel[] = [
|
||||
{ id: 'ref-001', name: 'Roberto Jiménez', type: 'person', contactName: 'Roberto Jiménez', contactPhone: '+506 8834-2291', contactEmail: 'rjimenez@email.com', note: 'Long-time VIP client — strong auto & life referrals', active: true, createdAt: '2024-06-10T10:00:00Z' },
|
||||
{ id: 'ref-002', name: 'Constructora Delta', type: 'company', contactName: 'Ing. Carlos Mora', contactPhone: '+506 2245-8800', contactEmail: 'cmora@delta.cr', note: 'Construction company — fleet and liability leads', active: true, createdAt: '2024-08-15T10:00:00Z' },
|
||||
{ id: 'ref-003', name: 'Instagram Ads', type: 'digital', contactName: '', contactPhone: '', contactEmail: '', note: 'Paid social campaigns — auto & health focus', active: true, createdAt: '2025-01-10T10:00:00Z' },
|
||||
{ id: 'ref-004', name: 'Google Ads', type: 'digital', contactName: '', contactPhone: '', contactEmail: '', note: 'Search campaigns — high intent leads', active: true, createdAt: '2025-01-10T10:00:00Z' },
|
||||
{ id: 'ref-005', name: 'Expo Comercio 2025', type: 'event', contactName: 'Comité Organizador', contactPhone: '+506 2222-0000', contactEmail: 'info@expocomercio.cr', note: 'Annual trade expo — collected 40+ contacts', active: false, createdAt: '2025-03-20T10:00:00Z' },
|
||||
{ id: 'ref-006', name: 'Cámara de Comercio', type: 'company', contactName: 'Patricia Arias', contactPhone: '+506 2233-5500', contactEmail: 'parias@camara.cr', note: 'Chamber of commerce partnership — corporate referrals', active: true, createdAt: '2024-11-05T10:00:00Z' },
|
||||
{ id: 'ref-007', name: 'Walk-in / Oficina', type: 'other', contactName: '', contactPhone: '', contactEmail: '', note: 'Foot traffic to main office', active: true, createdAt: '2024-01-01T10:00:00Z' },
|
||||
]
|
||||
|
||||
export function useReferralChannels() {
|
||||
const channels = useLocalStorageRef<ReferralChannel[]>(KEY, () => [])
|
||||
|
||||
// Seed on first use
|
||||
if (import.meta.client && channels.value.length === 0) {
|
||||
channels.value = [...SEED_CHANNELS]
|
||||
}
|
||||
|
||||
function addChannel(entry: Omit<ReferralChannel, 'id' | 'createdAt'>) {
|
||||
const channel: ReferralChannel = {
|
||||
id: 'ref-' + (crypto.randomUUID?.() ?? String(Date.now())).slice(0, 8),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry,
|
||||
}
|
||||
channels.value = [channel, ...channels.value]
|
||||
return channel
|
||||
}
|
||||
|
||||
function updateChannel(id: string, updates: Partial<Omit<ReferralChannel, 'id' | 'createdAt'>>) {
|
||||
channels.value = channels.value.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
)
|
||||
}
|
||||
|
||||
function removeChannel(id: string) {
|
||||
channels.value = channels.value.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
const activeChannels = computed(() => channels.value.filter(c => c.active))
|
||||
|
||||
/** Flat list for use in dropdowns */
|
||||
const channelOptions = computed(() =>
|
||||
activeChannels.value.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
return { channels, activeChannels, channelOptions, addChannel, updateChannel, removeChannel }
|
||||
}
|
||||
316
app/composables/useSalesPipeline.ts
Normal file
316
app/composables/useSalesPipeline.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Sales pipeline tracker — per-deal stage + form completion tracking.
|
||||
* Persisted in localStorage. Each deal flows:
|
||||
* Customer → Get Quotes → [waiting] → Present Quotes → [waiting] → Solicitud → Emission
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export type PipelineStage =
|
||||
| 'customer'
|
||||
| 'get_quotes'
|
||||
| 'waiting_carriers'
|
||||
| 'present_quotes'
|
||||
| 'waiting_client'
|
||||
| 'solicitud'
|
||||
| 'emission'
|
||||
|
||||
export type FormStatus = 'not_started' | 'in_progress' | 'complete'
|
||||
|
||||
export interface DealForm {
|
||||
id: string
|
||||
label: string
|
||||
/** 0–100 */
|
||||
completionPct: number
|
||||
status: FormStatus
|
||||
requiredFields: number
|
||||
completedFields: number
|
||||
}
|
||||
|
||||
export interface SalesDeal {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
productLine: string
|
||||
currentStage: PipelineStage
|
||||
/** Stages that have been fully completed */
|
||||
completedStages: PipelineStage[]
|
||||
/** ISO timestamps for when each stage was entered */
|
||||
stageTimestamps: Partial<Record<PipelineStage, string>>
|
||||
/** Forms assigned to this deal, keyed by stage */
|
||||
forms: Partial<Record<PipelineStage, DealForm[]>>
|
||||
/** Optional carrier info */
|
||||
carrier?: string
|
||||
carrierProduct?: string
|
||||
/** Bind token linking compare → solicitud */
|
||||
bindToken?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-sales-pipeline-v1'
|
||||
|
||||
/** Ordered stages for rendering */
|
||||
export const PIPELINE_STAGES: { id: PipelineStage; label: string; isWaiting: boolean }[] = [
|
||||
{ id: 'customer', label: 'Customer', isWaiting: false },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', isWaiting: false },
|
||||
{ id: 'waiting_carriers', label: 'Awaiting Carriers', isWaiting: true },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', isWaiting: false },
|
||||
{ id: 'waiting_client', label: 'Awaiting Client', isWaiting: true },
|
||||
{ id: 'solicitud', label: 'Solicitud', isWaiting: false },
|
||||
{ id: 'emission', label: 'Emission', isWaiting: false },
|
||||
]
|
||||
|
||||
function stageIndex(stage: PipelineStage): number {
|
||||
return PIPELINE_STAGES.findIndex(s => s.id === stage)
|
||||
}
|
||||
|
||||
/** Default forms per stage (seeded when deal enters a stage) */
|
||||
function defaultFormsForStage(stage: PipelineStage, productLine: string): DealForm[] {
|
||||
switch (stage) {
|
||||
case 'customer':
|
||||
return [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 0, status: 'not_started', requiredFields: 8, completedFields: 0 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 0, status: 'not_started', requiredFields: 3, completedFields: 0 },
|
||||
]
|
||||
case 'get_quotes':
|
||||
return [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 0, status: 'not_started', requiredFields: 12, completedFields: 0 },
|
||||
{ id: 'risk-details', label: `${productLine} risk details`, completionPct: 0, status: 'not_started', requiredFields: 10, completedFields: 0 },
|
||||
]
|
||||
case 'solicitud':
|
||||
return [
|
||||
{ id: 'solicitud-form', label: 'Solicitud de seguro', completionPct: 0, status: 'not_started', requiredFields: 18, completedFields: 0 },
|
||||
{ id: 'payment-auth', label: 'Payment authorization', completionPct: 0, status: 'not_started', requiredFields: 5, completedFields: 0 },
|
||||
{ id: 'beneficiaries', label: 'Beneficiary designation', completionPct: 0, status: 'not_started', requiredFields: 4, completedFields: 0 },
|
||||
]
|
||||
case 'emission':
|
||||
return [
|
||||
{ id: 'policy-review', label: 'Policy review checklist', completionPct: 0, status: 'not_started', requiredFields: 6, completedFields: 0 },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed demo deals */
|
||||
const SEED_DEALS: SalesDeal[] = [
|
||||
{
|
||||
id: 'deal-001',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
productLine: 'Auto',
|
||||
currentStage: 'waiting_carriers',
|
||||
completedStages: ['customer', 'get_quotes'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-04-02T09:00:00Z',
|
||||
get_quotes: '2026-04-02T09:30:00Z',
|
||||
waiting_carriers: '2026-04-02T10:00:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 100, status: 'complete', requiredFields: 3, completedFields: 3 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 100, status: 'complete', requiredFields: 12, completedFields: 12 },
|
||||
{ id: 'risk-details', label: 'Auto risk details', completionPct: 100, status: 'complete', requiredFields: 10, completedFields: 10 },
|
||||
],
|
||||
},
|
||||
createdAt: '2026-04-02T09:00:00Z',
|
||||
updatedAt: '2026-04-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'deal-002',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
productLine: 'Life',
|
||||
currentStage: 'solicitud',
|
||||
completedStages: ['customer', 'get_quotes', 'waiting_carriers', 'present_quotes', 'waiting_client'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-03-28T11:00:00Z',
|
||||
get_quotes: '2026-03-28T11:30:00Z',
|
||||
waiting_carriers: '2026-03-28T12:00:00Z',
|
||||
present_quotes: '2026-04-01T14:00:00Z',
|
||||
waiting_client: '2026-04-01T15:00:00Z',
|
||||
solicitud: '2026-04-03T09:00:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 100, status: 'complete', requiredFields: 3, completedFields: 3 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 100, status: 'complete', requiredFields: 12, completedFields: 12 },
|
||||
{ id: 'risk-details', label: 'Life risk details', completionPct: 100, status: 'complete', requiredFields: 10, completedFields: 10 },
|
||||
],
|
||||
solicitud: [
|
||||
{ id: 'solicitud-form', label: 'Solicitud de seguro', completionPct: 72, status: 'in_progress', requiredFields: 18, completedFields: 13 },
|
||||
{ id: 'payment-auth', label: 'Payment authorization', completionPct: 40, status: 'in_progress', requiredFields: 5, completedFields: 2 },
|
||||
{ id: 'beneficiaries', label: 'Beneficiary designation', completionPct: 0, status: 'not_started', requiredFields: 4, completedFields: 0 },
|
||||
],
|
||||
},
|
||||
carrier: 'ASSA',
|
||||
carrierProduct: 'Universal II',
|
||||
bindToken: 'bind-abc123',
|
||||
createdAt: '2026-03-28T11:00:00Z',
|
||||
updatedAt: '2026-04-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'deal-003',
|
||||
customerId: 'cust-005',
|
||||
customerName: 'Sofía Rojas Delgado',
|
||||
productLine: 'Auto',
|
||||
currentStage: 'get_quotes',
|
||||
completedStages: ['customer'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-04-05T08:00:00Z',
|
||||
get_quotes: '2026-04-05T08:15:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 67, status: 'in_progress', requiredFields: 3, completedFields: 2 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 50, status: 'in_progress', requiredFields: 12, completedFields: 6 },
|
||||
{ id: 'risk-details', label: 'Auto risk details', completionPct: 0, status: 'not_started', requiredFields: 10, completedFields: 0 },
|
||||
],
|
||||
},
|
||||
createdAt: '2026-04-05T08:00:00Z',
|
||||
updatedAt: '2026-04-05T08:15:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export function useSalesPipeline() {
|
||||
const deals = useLocalStorageRef<SalesDeal[]>(KEY, () => [])
|
||||
|
||||
// Seed on first use
|
||||
if (import.meta.client && deals.value.length === 0) {
|
||||
deals.value = [...SEED_DEALS]
|
||||
}
|
||||
|
||||
/** Get a deal by ID */
|
||||
function getDeal(dealId: string) {
|
||||
return deals.value.find(d => d.id === dealId)
|
||||
}
|
||||
|
||||
/** Get deals for a specific customer */
|
||||
function getDealsForCustomer(customerId: string) {
|
||||
return deals.value.filter(d => d.customerId === customerId)
|
||||
}
|
||||
|
||||
/** Get the active (most recent non-emission) deal for a customer */
|
||||
function getActiveDeal(customerId: string) {
|
||||
return deals.value
|
||||
.filter(d => d.customerId === customerId && d.currentStage !== 'emission')
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
|
||||
}
|
||||
|
||||
/** Create a new deal */
|
||||
function createDeal(customerId: string, customerName: string, productLine: string): SalesDeal {
|
||||
const now = new Date().toISOString()
|
||||
const deal: SalesDeal = {
|
||||
id: 'deal-' + (crypto.randomUUID?.() ?? String(Date.now())).slice(0, 8),
|
||||
customerId,
|
||||
customerName,
|
||||
productLine,
|
||||
currentStage: 'customer',
|
||||
completedStages: [],
|
||||
stageTimestamps: { customer: now },
|
||||
forms: { customer: defaultFormsForStage('customer', productLine) },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
deals.value = [deal, ...deals.value]
|
||||
return deal
|
||||
}
|
||||
|
||||
/** Advance deal to the next stage */
|
||||
function advanceStage(dealId: string) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
|
||||
const currentIdx = stageIndex(deal.currentStage)
|
||||
const nextStage = PIPELINE_STAGES[currentIdx + 1]
|
||||
if (!nextStage) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
deal.completedStages = [...new Set([...deal.completedStages, deal.currentStage])]
|
||||
deal.currentStage = nextStage.id
|
||||
deal.stageTimestamps = { ...deal.stageTimestamps, [nextStage.id]: now }
|
||||
deal.updatedAt = now
|
||||
|
||||
// Seed forms for the new stage if not already present
|
||||
if (!deal.forms[nextStage.id]) {
|
||||
deal.forms = { ...deal.forms, [nextStage.id]: defaultFormsForStage(nextStage.id, deal.productLine) }
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Set deal to a specific stage (e.g., when quotes arrive) */
|
||||
function setStage(dealId: string, stage: PipelineStage) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
// Mark all stages before the target as completed
|
||||
const targetIdx = stageIndex(stage)
|
||||
const completed = PIPELINE_STAGES.slice(0, targetIdx).map(s => s.id)
|
||||
deal.completedStages = [...new Set([...deal.completedStages, ...completed])]
|
||||
deal.currentStage = stage
|
||||
deal.stageTimestamps = { ...deal.stageTimestamps, [stage]: now }
|
||||
deal.updatedAt = now
|
||||
|
||||
if (!deal.forms[stage]) {
|
||||
deal.forms = { ...deal.forms, [stage]: defaultFormsForStage(stage, deal.productLine) }
|
||||
}
|
||||
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Update form completion within a deal */
|
||||
function updateFormProgress(dealId: string, stage: PipelineStage, formId: string, completedFields: number) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
const stageForms = deal.forms[stage]
|
||||
if (!stageForms) return
|
||||
const form = stageForms.find(f => f.id === formId)
|
||||
if (!form) return
|
||||
|
||||
form.completedFields = Math.min(completedFields, form.requiredFields)
|
||||
form.completionPct = form.requiredFields > 0 ? Math.round((form.completedFields / form.requiredFields) * 100) : 100
|
||||
form.status = form.completionPct === 0 ? 'not_started' : form.completionPct === 100 ? 'complete' : 'in_progress'
|
||||
deal.updatedAt = new Date().toISOString()
|
||||
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Remove a deal */
|
||||
function removeDeal(dealId: string) {
|
||||
deals.value = deals.value.filter(d => d.id !== dealId)
|
||||
}
|
||||
|
||||
/** Computed: stage completion percentage for a deal's current stage forms */
|
||||
function stageFormProgress(deal: SalesDeal, stage: PipelineStage): number {
|
||||
const forms = deal.forms[stage]
|
||||
if (!forms || forms.length === 0) return 0
|
||||
const totalRequired = forms.reduce((s, f) => s + f.requiredFields, 0)
|
||||
const totalCompleted = forms.reduce((s, f) => s + f.completedFields, 0)
|
||||
return totalRequired > 0 ? Math.round((totalCompleted / totalRequired) * 100) : 100
|
||||
}
|
||||
|
||||
return {
|
||||
deals,
|
||||
getDeal,
|
||||
getDealsForCustomer,
|
||||
getActiveDeal,
|
||||
createDeal,
|
||||
advanceStage,
|
||||
setStage,
|
||||
updateFormProgress,
|
||||
removeDeal,
|
||||
stageFormProgress,
|
||||
}
|
||||
}
|
||||
23
app/composables/useSidebarFeatures.ts
Normal file
23
app/composables/useSidebarFeatures.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface SidebarFeatures {
|
||||
showWorkstations: boolean
|
||||
showAiTools: boolean
|
||||
showLeadsHub: boolean
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-sidebar-features-v1'
|
||||
|
||||
let _shared: Ref<SidebarFeatures> | null = null
|
||||
|
||||
export function useSidebarFeatures() {
|
||||
if (!_shared) {
|
||||
_shared = useLocalStorageRef<SidebarFeatures>(KEY, () => ({
|
||||
showWorkstations: false,
|
||||
showAiTools: false,
|
||||
showLeadsHub: true,
|
||||
}))
|
||||
}
|
||||
return _shared
|
||||
}
|
||||
21
app/composables/useSuperAdmin.ts
Normal file
21
app/composables/useSuperAdmin.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Tenant-level admin (company logo, legal name, etc.).
|
||||
* Wire to real roles/claims when auth exists. Until then:
|
||||
* - localStorage `policy-ui.superadmin` = `1` | `0`
|
||||
* - in dev, defaults to true when the key is unset so the org screen is reachable
|
||||
*/
|
||||
export function useSuperAdmin() {
|
||||
const isSuperAdmin = computed(() => {
|
||||
if (!import.meta.client) return false
|
||||
try {
|
||||
const v = localStorage.getItem('policy-ui.superadmin')
|
||||
if (v === '1') return true
|
||||
if (v === '0') return false
|
||||
return import.meta.dev
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return { isSuperAdmin }
|
||||
}
|
||||
184
app/composables/useSupportTickets.ts
Normal file
184
app/composables/useSupportTickets.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Support Tickets — composable for queue state, filtering, CRUD, and mock routing.
|
||||
* Persisted in localStorage via useLocalStorageRef.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import {
|
||||
type SupportTicket,
|
||||
type SupportTicketDetail,
|
||||
type TicketMessage,
|
||||
type TicketStatus,
|
||||
type SupportChannel,
|
||||
type RoutingTier,
|
||||
type RoutedQueue,
|
||||
type RoutingRule,
|
||||
MOCK_SUPPORT_TICKETS,
|
||||
MOCK_TICKET_DETAILS,
|
||||
MOCK_ROUTING_RULES,
|
||||
} from '~/data/mock-support'
|
||||
|
||||
interface SupportState {
|
||||
tickets: SupportTicket[]
|
||||
details: Record<string, SupportTicketDetail>
|
||||
routingRules: RoutingRule[]
|
||||
}
|
||||
|
||||
function buildDefaults(): SupportState {
|
||||
return {
|
||||
tickets: [...MOCK_SUPPORT_TICKETS],
|
||||
details: { ...MOCK_TICKET_DETAILS },
|
||||
routingRules: [...MOCK_ROUTING_RULES],
|
||||
}
|
||||
}
|
||||
|
||||
export function useSupportTickets() {
|
||||
const state = useLocalStorageRef<SupportState>('policy-ui-support-tickets-v1', buildDefaults)
|
||||
|
||||
// ── Computed: filtered lists ──
|
||||
const openTickets = computed(() => state.value.tickets.filter(t => t.status === 'open'))
|
||||
const unresolvedCount = computed(() => state.value.tickets.filter(t => t.status !== 'resolved').length)
|
||||
const breachedCount = computed(() => state.value.tickets.filter(t => t.slaPercent >= 100).length)
|
||||
const unassignedCount = computed(() => state.value.tickets.filter(t => !t.assignedTo).length)
|
||||
const openPoolCount = computed(() => state.value.tickets.filter(t => t.routedQueue === 'open_pool').length)
|
||||
const inProgressCount = computed(() => state.value.tickets.filter(t => t.status === 'in_progress').length)
|
||||
|
||||
const kpis = computed(() => {
|
||||
const all = state.value.tickets
|
||||
const unresolved = all.filter(t => t.status !== 'resolved')
|
||||
const avgDaysOpen = unresolved.length
|
||||
? Math.round(unresolved.reduce((sum, t) => sum + t.daysOpen, 0) / unresolved.length)
|
||||
: 0
|
||||
return {
|
||||
total: all.length,
|
||||
open: openTickets.value.length,
|
||||
inProgress: inProgressCount.value,
|
||||
breached: breachedCount.value,
|
||||
avgDaysOpen,
|
||||
unassigned: unassignedCount.value,
|
||||
openPool: openPoolCount.value,
|
||||
}
|
||||
})
|
||||
|
||||
// ── CRUD ──
|
||||
function updateStatus(ticketId: string, status: TicketStatus) {
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.status = status
|
||||
ticket.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const detail = state.value.details[ticketId]
|
||||
if (detail) {
|
||||
detail.status = status
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
function assignTicket(ticketId: string, agent: string) {
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.assignedTo = agent
|
||||
ticket.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const detail = state.value.details[ticketId]
|
||||
if (detail) {
|
||||
detail.assignedTo = agent
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
detail.messages.push({
|
||||
id: `msg-${Date.now()}`,
|
||||
type: 'system',
|
||||
direction: 'internal',
|
||||
from: 'Sistema',
|
||||
to: null,
|
||||
subject: null,
|
||||
body: `Ticket asignado a ${agent}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
aiDigest: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(ticketId: string, message: Omit<TicketMessage, 'id' | 'timestamp'>) {
|
||||
const detail = state.value.details[ticketId]
|
||||
if (!detail) return
|
||||
const msg: TicketMessage = {
|
||||
...message,
|
||||
id: `msg-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
detail.messages.push(msg)
|
||||
detail.messageCount = detail.messages.length
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
detail.lastMessagePreview = msg.body.slice(0, 80)
|
||||
|
||||
// sync summary ticket
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.messageCount = detail.messageCount
|
||||
ticket.updatedAt = detail.updatedAt
|
||||
ticket.lastMessagePreview = detail.lastMessagePreview
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock Routing ──
|
||||
const routingKeywords: Record<RoutedQueue, string[]> = {
|
||||
collections: ['pago', 'factura', 'cobro', 'recibo', 'transferencia', 'mora'],
|
||||
claims: ['siniestro', 'accidente', 'robo', 'daño', 'choque', 'grúa', 'colisión'],
|
||||
sales: ['cotización', 'seguro nuevo', 'precio', 'cuánto sale', 'cobertura'],
|
||||
renewals: ['renovación', 'vencimiento', 'prórroga', 'vigencia'],
|
||||
operations: ['endoso', 'certificado', 'modificar', 'cambio de beneficiario'],
|
||||
open_pool: [],
|
||||
}
|
||||
|
||||
function simulateRouting(channel: SupportChannel, body: string): { tier: RoutingTier; queue: RoutedQueue; confidence: number } {
|
||||
const lower = body.toLowerCase()
|
||||
|
||||
// Tier 2: keyword matching
|
||||
for (const [queue, keywords] of Object.entries(routingKeywords) as [RoutedQueue, string[]][]) {
|
||||
if (!keywords.length) continue
|
||||
const matched = keywords.filter(kw => lower.includes(kw))
|
||||
if (matched.length > 0) {
|
||||
const confidence = Math.min(0.95, 0.6 + matched.length * 0.1)
|
||||
return { tier: 'tier2_rule', queue, confidence }
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: no match → open pool
|
||||
return { tier: 'tier3_open', queue: 'open_pool', confidence: 0.3 }
|
||||
}
|
||||
|
||||
// ── Routing Rules CRUD ──
|
||||
function toggleRule(ruleId: string) {
|
||||
const rule = state.value.routingRules.find(r => r.id === ruleId)
|
||||
if (rule) rule.enabled = !rule.enabled
|
||||
}
|
||||
|
||||
function updateRule(ruleId: string, updates: Partial<RoutingRule>) {
|
||||
const rule = state.value.routingRules.find(r => r.id === ruleId)
|
||||
if (rule) Object.assign(rule, updates)
|
||||
}
|
||||
|
||||
function getDetail(ticketId: string): SupportTicketDetail | undefined {
|
||||
return state.value.details[ticketId]
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
// computed
|
||||
openTickets,
|
||||
unresolvedCount,
|
||||
breachedCount,
|
||||
unassignedCount,
|
||||
openPoolCount,
|
||||
inProgressCount,
|
||||
kpis,
|
||||
// CRUD
|
||||
updateStatus,
|
||||
assignTicket,
|
||||
addMessage,
|
||||
getDetail,
|
||||
// routing
|
||||
simulateRouting,
|
||||
toggleRule,
|
||||
updateRule,
|
||||
}
|
||||
}
|
||||
25
app/composables/useWelcomeDashboard.ts
Normal file
25
app/composables/useWelcomeDashboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { WelcomeDashboardConfig } from '~/types/welcome-dashboard'
|
||||
|
||||
/**
|
||||
* Home / welcome dashboard content. Reads `app.config` first; later merge runtime config or APIs.
|
||||
* Brokerage company name from Settings → Personalization overrides `productName` when set.
|
||||
*/
|
||||
export function useWelcomeDashboard(): ComputedRef<WelcomeDashboardConfig> {
|
||||
const app = useAppConfig()
|
||||
const { saved: branding } = useBrokerageBranding()
|
||||
|
||||
return computed((): WelcomeDashboardConfig => {
|
||||
const base = (app.welcomeDashboard ?? {}) as Partial<WelcomeDashboardConfig>
|
||||
const fromBranding = branding.value.companyName?.trim()
|
||||
return {
|
||||
greetingName: base.greetingName ?? 'User',
|
||||
productName: fromBranding || base.productName || 'Segur-OS Beta',
|
||||
subtitle: base.subtitle ?? '',
|
||||
dailyTasks: base.dailyTasks ?? [],
|
||||
alerts: base.alerts ?? [],
|
||||
performanceKpis: base.performanceKpis ?? [],
|
||||
ceoKpis: base.ceoKpis ?? [],
|
||||
quickLinks: base.quickLinks ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
70
app/data/auto-quote-intake.ts
Normal file
70
app/data/auto-quote-intake.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** Auto quoting — option lists aligned with grid filters / production LOB vocabulary (ES labels). */
|
||||
|
||||
export const AUTO_RAMO_LABEL = 'Vehículos'
|
||||
|
||||
/** First option is empty — pair with placeholder “Select one” in UI */
|
||||
export const AUTO_SUB_RAMO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Cobertura completa', value: 'cobertura_completa' },
|
||||
{ label: 'Daños a terceros', value: 'danos_terceros' }
|
||||
]
|
||||
|
||||
export const AUTO_CLASE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Pickup', value: 'pickup' },
|
||||
{ label: 'Mula', value: 'mula' },
|
||||
{ label: 'Van', value: 'van' },
|
||||
{ label: 'MiniVan', value: 'minivan' },
|
||||
{ label: 'Panel', value: 'panel' },
|
||||
{ label: 'Camión', value: 'camion' },
|
||||
{ label: 'Camioneta', value: 'camioneta' },
|
||||
{ label: 'Sedan', value: 'sedan' },
|
||||
{ label: 'Cabezal', value: 'cabezal' },
|
||||
{ label: 'Bus (0-15)', value: 'bus_0_15' },
|
||||
{ label: 'Bus (16-30)', value: 'bus_16_30' },
|
||||
{ label: 'Bus (+30)', value: 'bus_30_plus' },
|
||||
{ label: 'Liviano', value: 'liviano' },
|
||||
{ label: 'Mediano', value: 'mediano' },
|
||||
{ label: 'Pesado', value: 'pesado' },
|
||||
{ label: 'Moto', value: 'moto' }
|
||||
]
|
||||
|
||||
export const AUTO_USO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Particular', value: 'particular' },
|
||||
{ label: 'Comercial', value: 'comercial' }
|
||||
]
|
||||
|
||||
export const AUTO_MARCA_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Toyota', value: 'toyota' }
|
||||
]
|
||||
|
||||
export const AUTO_MODELO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Cellica', value: 'cellica' },
|
||||
{ label: 'Corolla', value: 'corolla' }
|
||||
]
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
export const AUTO_YEAR_OPTIONS: { label: string; value: string }[] = [
|
||||
...Array.from({ length: 22 }, (_, i) => {
|
||||
const y = String(currentYear - i)
|
||||
return { label: y, value: y }
|
||||
})
|
||||
]
|
||||
|
||||
/**
|
||||
* Carriers available for solicit — in production, matches providers configured under Settings / Providers
|
||||
* (each has a quoting email on file).
|
||||
*/
|
||||
export const AUTO_QUOTE_CARRIERS: { id: string; name: string; detail: string }[] = [
|
||||
{ id: 'mapfre', name: 'Mapfre', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'seguros_aurora', name: 'Seguros Aurora', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'continental', name: 'Continental', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'internacional', name: 'Internacional', detail: 'Quoting email on file in provider profile' }
|
||||
]
|
||||
|
||||
/** Predetermined plan packages — comparative mode builds side-by-side rows from these */
|
||||
export const AUTO_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'cc_full', label: 'Cobertura completa', hint: 'Collision, comprehensive, liability' },
|
||||
{ id: 'dat', label: 'Daños a terceros', hint: 'Third-party liability' },
|
||||
{ id: 'plan_corporate', label: 'Paquete corporativo', hint: 'Fleet-friendly endorsements' }
|
||||
]
|
||||
41
app/data/form-field-groups.json
Normal file
41
app/data/form-field-groups.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 1,
|
||||
"groups": [
|
||||
{
|
||||
"id": "kyc_identity",
|
||||
"title": "Identificación",
|
||||
"description": "Cédula, pasaporte, PEP según SSRP",
|
||||
"fieldKeys": ["full_name", "document_id", "document_expiry", "nationality"]
|
||||
},
|
||||
{
|
||||
"id": "life_risk_health",
|
||||
"title": "Salud y hábitos (vida)",
|
||||
"description": "Declaración de salud, tabaco, deportes de riesgo",
|
||||
"fieldKeys": ["smoking", "height_cm", "weight_kg", "medical_conditions"]
|
||||
},
|
||||
{
|
||||
"id": "auto_vehicle",
|
||||
"title": "Vehículo",
|
||||
"description": "Placa, uso, valor, accesorios",
|
||||
"fieldKeys": ["plate", "vin", "year", "use_type", "declared_value"]
|
||||
},
|
||||
{
|
||||
"id": "health_local_cover",
|
||||
"title": "Salud local · cobertura",
|
||||
"description": "Red, deducible, preexistencias",
|
||||
"fieldKeys": ["network", "deductible_usd", "preexisting_disclosure"]
|
||||
},
|
||||
{
|
||||
"id": "health_intl_cover",
|
||||
"title": "Salud internacional",
|
||||
"description": "Zona de cobertura, repatriación, USA cover",
|
||||
"fieldKeys": ["coverage_zone", "usa_cover", "repatriation"]
|
||||
},
|
||||
{
|
||||
"id": "home_property",
|
||||
"title": "Propiedad (hogar)",
|
||||
"description": "Ubicación, construcción, suma contenido",
|
||||
"fieldKeys": ["address", "construction_type", "contents_sum"]
|
||||
}
|
||||
]
|
||||
}
|
||||
116
app/data/forms-catalog.json
Normal file
116
app/data/forms-catalog.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"version": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": 39,
|
||||
"description": "1031083 GE_ADB_GEN_OPTIMA_DIG 250507",
|
||||
"insurerSlugs": ["optima"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "17569365751031083-GE_ADB_GEN_OPTIMA.pdf",
|
||||
"badge": 39,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"description": "1031082 GE_ADB_GEN_OPTIMA_DAT",
|
||||
"insurerSlugs": ["optima"],
|
||||
"subRamoKey": "vehiculos_dat",
|
||||
"subRamoLabel": "Vehículos · Daños a terceros (DAT)",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_dat_liability",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "OPTIMA_DAT_solicitud.pdf",
|
||||
"badge": 38,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"description": "MAPFRE_MUNDIAL_AUTO_FULL",
|
||||
"insurerSlugs": ["mapfre"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "MAPFRE_MUNDIAL_FOREVER_PLUS.pdf",
|
||||
"badge": 37,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"description": "ASSA_VIDA_UNIVERSAL_PACK",
|
||||
"insurerSlugs": ["assa"],
|
||||
"subRamoKey": "vida_universal",
|
||||
"subRamoLabel": "Vida universal · Protección y ahorro",
|
||||
"personKinds": "natural",
|
||||
"productLine": "life",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "ASSA_Universal_II_illustration.pdf",
|
||||
"badge": 36,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "life_risk_health"]
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"description": "SALUD_LOCAL_KYC",
|
||||
"insurerSlugs": ["mapfre", "assa"],
|
||||
"subRamoKey": "salud_local",
|
||||
"subRamoLabel": "Salud local",
|
||||
"personKinds": "both",
|
||||
"productLine": "health_local",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "KYC_salud_local.pdf",
|
||||
"badge": 35,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "health_local_cover"]
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"description": "SALUD_INTERNACIONAL_KYC",
|
||||
"insurerSlugs": ["palig", "mapfre"],
|
||||
"subRamoKey": "salud_internacional",
|
||||
"subRamoLabel": "Salud internacional / IPMI",
|
||||
"personKinds": "both",
|
||||
"productLine": "health_international",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "IPMI_solicitud.pdf",
|
||||
"badge": 34,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "health_intl_cover"]
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"description": "CEDULA_NATURAL",
|
||||
"insurerSlugs": ["optima", "mapfre", "assa", "acerta", "fedpa", "ancon"],
|
||||
"subRamoKey": "any",
|
||||
"subRamoLabel": "Cualquier ramo (identificación)",
|
||||
"personKinds": "natural",
|
||||
"productLine": null,
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "cedula_ejemplo.png",
|
||||
"kind": "identity",
|
||||
"fieldGroupIds": ["kyc_identity"]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"description": "ACERTA_VEH_FULL_1012013",
|
||||
"insurerSlugs": ["acerta"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "GE_ATC_GEN_ACERTA_DIG.pdf",
|
||||
"badge": 32,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
}
|
||||
]
|
||||
}
|
||||
53
app/data/health-quote-intake.ts
Normal file
53
app/data/health-quote-intake.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Health quoting — mock carriers, plans, and reference rate table (age bands). */
|
||||
|
||||
export const HEALTH_QUOTE_CARRIERS: { id: string; name: string; detail: string; hasPublishedRateTable?: boolean }[] = [
|
||||
{
|
||||
id: 'vida_plena',
|
||||
name: 'Vida Plena Salud',
|
||||
detail: 'Quoting email on file · published age-banded table',
|
||||
hasPublishedRateTable: true
|
||||
},
|
||||
{
|
||||
id: 'salud_global',
|
||||
name: 'Salud Global',
|
||||
detail: 'Quoting email on file',
|
||||
hasPublishedRateTable: false
|
||||
},
|
||||
{
|
||||
id: 'integral_med',
|
||||
name: 'Integral Medical',
|
||||
detail: 'Quoting email on file',
|
||||
hasPublishedRateTable: true
|
||||
}
|
||||
]
|
||||
|
||||
export const HEALTH_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'local_base', label: 'Local · Base', hint: 'In-country network, standard deductible' },
|
||||
{ id: 'local_plus', label: 'Local · Plus', hint: 'Broader network, lower copay' },
|
||||
{ id: 'intl_major', label: 'International · Major medical', hint: 'Evacuation + US/EU coverage tier' }
|
||||
]
|
||||
|
||||
export const HEALTH_COVERAGE_AREA: { label: string; value: string }[] = [
|
||||
{ label: 'Local', value: 'local' },
|
||||
{ label: 'International', value: 'international' }
|
||||
]
|
||||
|
||||
export const HEALTH_NETWORK_TIER: { label: string; value: string }[] = [
|
||||
{ label: 'Preferred / cerrado', value: 'preferred' },
|
||||
{ label: 'Open / amplio', value: 'open' }
|
||||
]
|
||||
|
||||
export const HEALTH_DEDUCTIBLE: { label: string; value: string }[] = [
|
||||
{ label: '$500', value: '500' },
|
||||
{ label: '$1,000', value: '1000' },
|
||||
{ label: '$2,500', value: '2500' }
|
||||
]
|
||||
|
||||
/** Mock age-band premium table (USD/mo) — some carriers publish this instead of email-only quotes */
|
||||
export const HEALTH_AGE_BAND_REFERENCE: { ageBand: string; employee: number; spouse: number; children: number }[] = [
|
||||
{ ageBand: '0–17', employee: 0, spouse: 0, children: 118 },
|
||||
{ ageBand: '18–29', employee: 142, spouse: 198, children: 0 },
|
||||
{ ageBand: '30–44', employee: 186, spouse: 251, children: 0 },
|
||||
{ ageBand: '45–54', employee: 264, spouse: 318, children: 0 },
|
||||
{ ageBand: '55–64', employee: 352, spouse: 401, children: 0 }
|
||||
]
|
||||
45
app/data/life-quote-intake.ts
Normal file
45
app/data/life-quote-intake.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Life quoting — mock carriers, plans, and option lists. */
|
||||
|
||||
export const LIFE_QUOTE_CARRIERS: { id: string; name: string; detail: string }[] = [
|
||||
{ id: 'vida_plena', name: 'Vida Plena', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'seguros_del_pacifico', name: 'Seguros del Pacífico', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'continental_life', name: 'Continental Life', detail: 'Quoting email on file in provider profile' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'term_basic', label: 'Term life · Basic', hint: 'Level-premium term, standard coverage' },
|
||||
{ id: 'term_plus', label: 'Term life · Plus', hint: 'Term with accidental-death rider' },
|
||||
{ id: 'whole_life', label: 'Whole life', hint: 'Permanent coverage with cash-value component' },
|
||||
{ id: 'keyman', label: 'Key person', hint: 'Business-owned policy on key employee' }
|
||||
]
|
||||
|
||||
export const LIFE_GENDER_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_TERM_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: '10 years', value: '10' },
|
||||
{ label: '15 years', value: '15' },
|
||||
{ label: '20 years', value: '20' },
|
||||
{ label: '30 years', value: '30' },
|
||||
{ label: 'Whole life', value: 'whole' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_AMOUNT_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: '$25,000', value: '25000' },
|
||||
{ label: '$50,000', value: '50000' },
|
||||
{ label: '$100,000', value: '100000' },
|
||||
{ label: '$250,000', value: '250000' },
|
||||
{ label: '$500,000', value: '500000' },
|
||||
{ label: '$1,000,000', value: '1000000' }
|
||||
]
|
||||
|
||||
export const LIFE_BENEFICIARY_RELATIONSHIP_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Spouse', value: 'spouse' },
|
||||
{ label: 'Child', value: 'child' },
|
||||
{ label: 'Parent', value: 'parent' },
|
||||
{ label: 'Sibling', value: 'sibling' },
|
||||
{ label: 'Business entity', value: 'business' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
363
app/data/mock-analytics.ts
Normal file
363
app/data/mock-analytics.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
// ─── Business Analytics — Types, Labels, Mock Data ───────────────────────────
|
||||
|
||||
export type AnalyticsTimePoint = { m: string; v: number; display: string }
|
||||
export type AnalyticsDomainId = 'production' | 'claims' | 'pipeline' | 'service'
|
||||
export type AnalyticsChartType = 'line' | 'bar' | 'area'
|
||||
|
||||
export interface AnalyticsMetric {
|
||||
id: string
|
||||
domain: AnalyticsDomainId
|
||||
label: string
|
||||
unit: string
|
||||
data12m: AnalyticsTimePoint[]
|
||||
change: string
|
||||
changeTone: 'positive' | 'negative' | 'neutral'
|
||||
defaultChartType: AnalyticsChartType
|
||||
}
|
||||
|
||||
export interface AnalyticsKpiSummary {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
change: string
|
||||
changeTone: 'positive' | 'negative' | 'neutral'
|
||||
hint: string
|
||||
sparkline: number[]
|
||||
}
|
||||
|
||||
// ─── Domain Labels ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_DOMAIN_LABELS: Record<AnalyticsDomainId, string> = {
|
||||
production: 'Producción',
|
||||
claims: 'Siniestros',
|
||||
pipeline: 'Pipeline',
|
||||
service: 'Servicio',
|
||||
}
|
||||
|
||||
// ─── Headline KPI Summaries ──────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_KPI_SUMMARIES: AnalyticsKpiSummary[] = [
|
||||
{ id: 'gwp', label: 'GWP Written', value: '$5.41M', change: '+6.2%', changeTone: 'positive', hint: 'Gross written premium YTD', sparkline: [72, 68, 76, 74, 81, 88, 85, 90, 87, 92, 95, 98] },
|
||||
{ id: 'policies', label: 'Active Policies', value: '342', change: '+12', changeTone: 'positive', hint: 'Currently active policies', sparkline: [290, 295, 298, 305, 310, 315, 318, 322, 328, 332, 338, 342] },
|
||||
{ id: 'loss-ratio', label: 'Loss Ratio', value: '58%', change: '-3.1%', changeTone: 'positive', hint: 'Claims paid / earned premium', sparkline: [68, 72, 65, 63, 60, 58, 61, 59, 57, 60, 58, 58] },
|
||||
{ id: 'retention', label: 'Retention Rate', value: '91%', change: '+1.2%', changeTone: 'positive', hint: 'Client renewal rate', sparkline: [86, 87, 88, 87, 89, 90, 89, 90, 91, 90, 91, 91] },
|
||||
{ id: 'open-claims', label: 'Open Claims', value: '7', change: '-2', changeTone: 'positive', hint: 'Currently unresolved claims', sparkline: [12, 11, 10, 9, 11, 10, 8, 9, 8, 7, 8, 7] },
|
||||
{ id: 'pipeline-value', label: 'Pipeline Value', value: '$6.2M', change: '+$820K', changeTone: 'positive', hint: 'Open quoted premium', sparkline: [42, 45, 48, 50, 52, 55, 53, 56, 58, 60, 62, 65] },
|
||||
]
|
||||
|
||||
// ─── Metrics: Production & Revenue ───────────────────────────────────────────
|
||||
|
||||
const productionMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'gwp', domain: 'production', label: 'GWP Written', unit: '$',
|
||||
change: '+6.2%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 62, display: '$3.82M' }, { m: 'Jun', v: 65, display: '$4.01M' },
|
||||
{ m: 'Jul', v: 60, display: '$3.70M' }, { m: 'Aug', v: 68, display: '$4.19M' },
|
||||
{ m: 'Sep', v: 71, display: '$4.38M' }, { m: 'Oct', v: 72, display: '$4.52M' },
|
||||
{ m: 'Nov', v: 68, display: '$4.28M' }, { m: 'Dec', v: 76, display: '$4.71M' },
|
||||
{ m: 'Jan', v: 74, display: '$4.61M' }, { m: 'Feb', v: 81, display: '$4.98M' },
|
||||
{ m: 'Mar', v: 88, display: '$5.41M' }, { m: 'Apr', v: 91, display: '$5.58M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'premium-by-lob', domain: 'production', label: 'Premium by LOB', unit: '$',
|
||||
change: '+4.8%', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Auto', v: 85, display: '$1.82M' }, { m: 'Health', v: 72, display: '$1.54M' },
|
||||
{ m: 'Life', v: 48, display: '$1.03M' }, { m: 'Property', v: 35, display: '$750K' },
|
||||
{ m: 'Marine', v: 18, display: '$386K' }, { m: 'Liability', v: 12, display: '$257K' },
|
||||
{ m: 'Surety', v: 8, display: '$171K' }, { m: 'Travel', v: 5, display: '$107K' },
|
||||
{ m: 'Other', v: 3, display: '$64K' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'new-biz-vs-renewal', domain: 'production', label: 'New Biz vs Renewal', unit: '$',
|
||||
change: '+$820K', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 45, display: '$1.8M' }, { m: 'Jun', v: 48, display: '$1.9M' },
|
||||
{ m: 'Jul', v: 42, display: '$1.7M' }, { m: 'Aug', v: 52, display: '$2.1M' },
|
||||
{ m: 'Sep', v: 55, display: '$2.2M' }, { m: 'Oct', v: 52, display: '$2.4M' },
|
||||
{ m: 'Nov', v: 48, display: '$2.2M' }, { m: 'Dec', v: 62, display: '$2.9M' },
|
||||
{ m: 'Jan', v: 58, display: '$2.7M' }, { m: 'Feb', v: 64, display: '$3.0M' },
|
||||
{ m: 'Mar', v: 70, display: '$3.2M' }, { m: 'Apr', v: 73, display: '$3.4M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'commission-revenue', domain: 'production', label: 'Commission Revenue', unit: '$',
|
||||
change: '+8.4%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 48, display: '$590K' }, { m: 'Jun', v: 50, display: '$615K' },
|
||||
{ m: 'Jul', v: 46, display: '$565K' }, { m: 'Aug', v: 53, display: '$652K' },
|
||||
{ m: 'Sep', v: 55, display: '$680K' }, { m: 'Oct', v: 55, display: '$680K' },
|
||||
{ m: 'Nov', v: 52, display: '$640K' }, { m: 'Dec', v: 60, display: '$740K' },
|
||||
{ m: 'Jan', v: 58, display: '$715K' }, { m: 'Feb', v: 65, display: '$800K' },
|
||||
{ m: 'Mar', v: 72, display: '$886K' }, { m: 'Apr', v: 75, display: '$923K' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'policies-bound', domain: 'production', label: 'Policies Bound', unit: '#',
|
||||
change: '+12', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 28, display: '28' }, { m: 'Jun', v: 30, display: '30' },
|
||||
{ m: 'Jul', v: 25, display: '25' }, { m: 'Aug', v: 33, display: '33' },
|
||||
{ m: 'Sep', v: 35, display: '35' }, { m: 'Oct', v: 32, display: '32' },
|
||||
{ m: 'Nov', v: 28, display: '28' }, { m: 'Dec', v: 35, display: '35' },
|
||||
{ m: 'Jan', v: 38, display: '38' }, { m: 'Feb', v: 36, display: '36' },
|
||||
{ m: 'Mar', v: 42, display: '42' }, { m: 'Apr', v: 44, display: '44' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-premium', domain: 'production', label: 'Avg Premium', unit: '$',
|
||||
change: '+2.1%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 52, display: '$12.4K' }, { m: 'Jun', v: 53, display: '$12.6K' },
|
||||
{ m: 'Jul', v: 51, display: '$12.1K' }, { m: 'Aug', v: 54, display: '$12.8K' },
|
||||
{ m: 'Sep', v: 55, display: '$13.1K' }, { m: 'Oct', v: 56, display: '$13.3K' },
|
||||
{ m: 'Nov', v: 55, display: '$13.1K' }, { m: 'Dec', v: 57, display: '$13.6K' },
|
||||
{ m: 'Jan', v: 56, display: '$13.3K' }, { m: 'Feb', v: 58, display: '$13.8K' },
|
||||
{ m: 'Mar', v: 59, display: '$14.0K' }, { m: 'Apr', v: 60, display: '$14.3K' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Claims & Loss ──────────────────────────────────────────────────
|
||||
|
||||
const claimsMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'claims-count', domain: 'claims', label: 'Claims Opened', unit: '#',
|
||||
change: '-2', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 8, display: '8' }, { m: 'Jun', v: 6, display: '6' },
|
||||
{ m: 'Jul', v: 10, display: '10' }, { m: 'Aug', v: 7, display: '7' },
|
||||
{ m: 'Sep', v: 9, display: '9' }, { m: 'Oct', v: 8, display: '8' },
|
||||
{ m: 'Nov', v: 6, display: '6' }, { m: 'Dec', v: 5, display: '5' },
|
||||
{ m: 'Jan', v: 7, display: '7' }, { m: 'Feb', v: 8, display: '8' },
|
||||
{ m: 'Mar', v: 6, display: '6' }, { m: 'Apr', v: 5, display: '5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'loss-ratio', domain: 'claims', label: 'Loss Ratio', unit: '%',
|
||||
change: '-3.1%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 66, display: '66%' }, { m: 'Jun', v: 64, display: '64%' },
|
||||
{ m: 'Jul', v: 70, display: '70%' }, { m: 'Aug', v: 67, display: '67%' },
|
||||
{ m: 'Sep', v: 65, display: '65%' }, { m: 'Oct', v: 68, display: '68%' },
|
||||
{ m: 'Nov', v: 72, display: '72%' }, { m: 'Dec', v: 65, display: '65%' },
|
||||
{ m: 'Jan', v: 63, display: '63%' }, { m: 'Feb', v: 60, display: '60%' },
|
||||
{ m: 'Mar', v: 58, display: '58%' }, { m: 'Apr', v: 57, display: '57%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-resolution-days', domain: 'claims', label: 'Avg Resolution', unit: 'days',
|
||||
change: '-4d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 38, display: '38d' }, { m: 'Jun', v: 35, display: '35d' },
|
||||
{ m: 'Jul', v: 40, display: '40d' }, { m: 'Aug', v: 36, display: '36d' },
|
||||
{ m: 'Sep', v: 34, display: '34d' }, { m: 'Oct', v: 32, display: '32d' },
|
||||
{ m: 'Nov', v: 30, display: '30d' }, { m: 'Dec', v: 28, display: '28d' },
|
||||
{ m: 'Jan', v: 31, display: '31d' }, { m: 'Feb', v: 29, display: '29d' },
|
||||
{ m: 'Mar', v: 27, display: '27d' }, { m: 'Apr', v: 26, display: '26d' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reserve-trend', domain: 'claims', label: 'Reserve Trend', unit: '$',
|
||||
change: '-$45K', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 75, display: '$380K' }, { m: 'Jun', v: 70, display: '$355K' },
|
||||
{ m: 'Jul', v: 80, display: '$405K' }, { m: 'Aug', v: 72, display: '$365K' },
|
||||
{ m: 'Sep', v: 68, display: '$345K' }, { m: 'Oct', v: 65, display: '$330K' },
|
||||
{ m: 'Nov', v: 60, display: '$304K' }, { m: 'Dec', v: 58, display: '$294K' },
|
||||
{ m: 'Jan', v: 62, display: '$314K' }, { m: 'Feb', v: 57, display: '$289K' },
|
||||
{ m: 'Mar', v: 55, display: '$279K' }, { m: 'Apr', v: 52, display: '$264K' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'claims-by-status', domain: 'claims', label: 'Claims by Status', unit: '#',
|
||||
change: '', changeTone: 'neutral', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Open', v: 3, display: '3' }, { m: 'Review', v: 3, display: '3' },
|
||||
{ m: 'Docs', v: 1, display: '1' }, { m: 'Approved', v: 2, display: '2' },
|
||||
{ m: 'Denied', v: 0, display: '0' }, { m: 'Closed', v: 1, display: '1' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'severity-trend', domain: 'claims', label: 'Avg Severity', unit: '$',
|
||||
change: '-$2.1K', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 65, display: '$18.5K' }, { m: 'Jun', v: 60, display: '$17.1K' },
|
||||
{ m: 'Jul', v: 72, display: '$20.5K' }, { m: 'Aug', v: 63, display: '$17.9K' },
|
||||
{ m: 'Sep', v: 58, display: '$16.5K' }, { m: 'Oct', v: 55, display: '$15.7K' },
|
||||
{ m: 'Nov', v: 52, display: '$14.8K' }, { m: 'Dec', v: 50, display: '$14.2K' },
|
||||
{ m: 'Jan', v: 54, display: '$15.4K' }, { m: 'Feb', v: 48, display: '$13.7K' },
|
||||
{ m: 'Mar', v: 46, display: '$13.1K' }, { m: 'Apr', v: 44, display: '$12.5K' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Sales Pipeline ─────────────────────────────────────────────────
|
||||
|
||||
const pipelineMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'conversion-rate', domain: 'pipeline', label: 'Conversion Rate', unit: '%',
|
||||
change: '+2.8%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 28, display: '28%' }, { m: 'Jun', v: 30, display: '30%' },
|
||||
{ m: 'Jul', v: 27, display: '27%' }, { m: 'Aug', v: 32, display: '32%' },
|
||||
{ m: 'Sep', v: 31, display: '31%' }, { m: 'Oct', v: 33, display: '33%' },
|
||||
{ m: 'Nov', v: 30, display: '30%' }, { m: 'Dec', v: 35, display: '35%' },
|
||||
{ m: 'Jan', v: 34, display: '34%' }, { m: 'Feb', v: 36, display: '36%' },
|
||||
{ m: 'Mar', v: 38, display: '38%' }, { m: 'Apr', v: 39, display: '39%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pipeline-value', domain: 'pipeline', label: 'Pipeline Value', unit: '$',
|
||||
change: '+$820K', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 42, display: '$3.8M' }, { m: 'Jun', v: 45, display: '$4.1M' },
|
||||
{ m: 'Jul', v: 40, display: '$3.6M' }, { m: 'Aug', v: 48, display: '$4.4M' },
|
||||
{ m: 'Sep', v: 52, display: '$4.7M' }, { m: 'Oct', v: 55, display: '$5.0M' },
|
||||
{ m: 'Nov', v: 50, display: '$4.5M' }, { m: 'Dec', v: 58, display: '$5.3M' },
|
||||
{ m: 'Jan', v: 56, display: '$5.1M' }, { m: 'Feb', v: 60, display: '$5.5M' },
|
||||
{ m: 'Mar', v: 65, display: '$5.9M' }, { m: 'Apr', v: 68, display: '$6.2M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lead-sources', domain: 'pipeline', label: 'Lead Sources', unit: '#',
|
||||
change: '+18%', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Referral', v: 85, display: '85' }, { m: 'Walk-in', v: 42, display: '42' },
|
||||
{ m: 'WhatsApp', v: 38, display: '38' }, { m: 'Google', v: 28, display: '28' },
|
||||
{ m: 'Instagram', v: 22, display: '22' }, { m: 'Facebook', v: 15, display: '15' },
|
||||
{ m: 'Partner', v: 12, display: '12' }, { m: 'Event', v: 8, display: '8' },
|
||||
{ m: 'Other', v: 6, display: '6' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'quote-to-bind-days', domain: 'pipeline', label: 'Quote-to-Bind', unit: 'days',
|
||||
change: '-2d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 18, display: '18d' }, { m: 'Jun', v: 16, display: '16d' },
|
||||
{ m: 'Jul', v: 20, display: '20d' }, { m: 'Aug', v: 15, display: '15d' },
|
||||
{ m: 'Sep', v: 14, display: '14d' }, { m: 'Oct', v: 13, display: '13d' },
|
||||
{ m: 'Nov', v: 15, display: '15d' }, { m: 'Dec', v: 12, display: '12d' },
|
||||
{ m: 'Jan', v: 14, display: '14d' }, { m: 'Feb', v: 11, display: '11d' },
|
||||
{ m: 'Mar', v: 10, display: '10d' }, { m: 'Apr', v: 9, display: '9d' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent-performance', domain: 'pipeline', label: 'Agent Performance', unit: '#',
|
||||
change: '', changeTone: 'neutral', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Ana R.', v: 88, display: '44 pólizas' }, { m: 'Marco V.', v: 72, display: '36 pólizas' },
|
||||
{ m: 'Carlos V.', v: 64, display: '32 pólizas' }, { m: 'María F.', v: 56, display: '28 pólizas' },
|
||||
{ m: 'Luis G.', v: 40, display: '20 pólizas' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'win-rate', domain: 'pipeline', label: 'Win Rate', unit: '%',
|
||||
change: '+3.5%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 35, display: '35%' }, { m: 'Jun', v: 38, display: '38%' },
|
||||
{ m: 'Jul', v: 33, display: '33%' }, { m: 'Aug', v: 40, display: '40%' },
|
||||
{ m: 'Sep', v: 42, display: '42%' }, { m: 'Oct', v: 41, display: '41%' },
|
||||
{ m: 'Nov', v: 39, display: '39%' }, { m: 'Dec', v: 44, display: '44%' },
|
||||
{ m: 'Jan', v: 43, display: '43%' }, { m: 'Feb', v: 46, display: '46%' },
|
||||
{ m: 'Mar', v: 48, display: '48%' }, { m: 'Apr', v: 50, display: '50%' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Customer & Service ─────────────────────────────────────────────
|
||||
|
||||
const serviceMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'support-volume', domain: 'service', label: 'Tickets Opened', unit: '#',
|
||||
change: '+15%', changeTone: 'negative', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 22, display: '22' }, { m: 'Jun', v: 25, display: '25' },
|
||||
{ m: 'Jul', v: 28, display: '28' }, { m: 'Aug', v: 24, display: '24' },
|
||||
{ m: 'Sep', v: 30, display: '30' }, { m: 'Oct', v: 27, display: '27' },
|
||||
{ m: 'Nov', v: 32, display: '32' }, { m: 'Dec', v: 20, display: '20' },
|
||||
{ m: 'Jan', v: 26, display: '26' }, { m: 'Feb', v: 29, display: '29' },
|
||||
{ m: 'Mar', v: 34, display: '34' }, { m: 'Apr', v: 36, display: '36' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sla-compliance', domain: 'service', label: 'SLA Compliance', unit: '%',
|
||||
change: '+4%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 78, display: '78%' }, { m: 'Jun', v: 80, display: '80%' },
|
||||
{ m: 'Jul', v: 76, display: '76%' }, { m: 'Aug', v: 82, display: '82%' },
|
||||
{ m: 'Sep', v: 84, display: '84%' }, { m: 'Oct', v: 83, display: '83%' },
|
||||
{ m: 'Nov', v: 85, display: '85%' }, { m: 'Dec', v: 88, display: '88%' },
|
||||
{ m: 'Jan', v: 86, display: '86%' }, { m: 'Feb', v: 89, display: '89%' },
|
||||
{ m: 'Mar', v: 90, display: '90%' }, { m: 'Apr', v: 92, display: '92%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-response-time', domain: 'service', label: 'Avg Response Time', unit: 'hrs',
|
||||
change: '-1.2h', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 72, display: '5.8h' }, { m: 'Jun', v: 68, display: '5.4h' },
|
||||
{ m: 'Jul', v: 75, display: '6.0h' }, { m: 'Aug', v: 65, display: '5.2h' },
|
||||
{ m: 'Sep', v: 60, display: '4.8h' }, { m: 'Oct', v: 55, display: '4.4h' },
|
||||
{ m: 'Nov', v: 52, display: '4.2h' }, { m: 'Dec', v: 48, display: '3.8h' },
|
||||
{ m: 'Jan', v: 50, display: '4.0h' }, { m: 'Feb', v: 45, display: '3.6h' },
|
||||
{ m: 'Mar', v: 42, display: '3.4h' }, { m: 'Apr', v: 40, display: '3.2h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retention-rate', domain: 'service', label: 'Retention Rate', unit: '%',
|
||||
change: '+1.2%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 84, display: '84%' }, { m: 'Jun', v: 85, display: '85%' },
|
||||
{ m: 'Jul', v: 86, display: '86%' }, { m: 'Aug', v: 87, display: '87%' },
|
||||
{ m: 'Sep', v: 88, display: '88%' }, { m: 'Oct', v: 88, display: '88%' },
|
||||
{ m: 'Nov', v: 87, display: '87%' }, { m: 'Dec', v: 89, display: '89%' },
|
||||
{ m: 'Jan', v: 90, display: '90%' }, { m: 'Feb', v: 90, display: '90%' },
|
||||
{ m: 'Mar', v: 91, display: '91%' }, { m: 'Apr', v: 91, display: '91%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nps-score', domain: 'service', label: 'NPS Score', unit: '#',
|
||||
change: '+5', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 42, display: '42' }, { m: 'Jun', v: 44, display: '44' },
|
||||
{ m: 'Jul', v: 40, display: '40' }, { m: 'Aug', v: 46, display: '46' },
|
||||
{ m: 'Sep', v: 48, display: '48' }, { m: 'Oct', v: 50, display: '50' },
|
||||
{ m: 'Nov', v: 48, display: '48' }, { m: 'Dec', v: 52, display: '52' },
|
||||
{ m: 'Jan', v: 54, display: '54' }, { m: 'Feb', v: 55, display: '55' },
|
||||
{ m: 'Mar', v: 58, display: '58' }, { m: 'Apr', v: 60, display: '60' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ticket-resolution-days', domain: 'service', label: 'Ticket Resolution', unit: 'days',
|
||||
change: '-0.8d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 55, display: '4.4d' }, { m: 'Jun', v: 52, display: '4.2d' },
|
||||
{ m: 'Jul', v: 58, display: '4.6d' }, { m: 'Aug', v: 50, display: '4.0d' },
|
||||
{ m: 'Sep', v: 48, display: '3.8d' }, { m: 'Oct', v: 45, display: '3.6d' },
|
||||
{ m: 'Nov', v: 42, display: '3.4d' }, { m: 'Dec', v: 40, display: '3.2d' },
|
||||
{ m: 'Jan', v: 43, display: '3.4d' }, { m: 'Feb', v: 38, display: '3.0d' },
|
||||
{ m: 'Mar', v: 36, display: '2.9d' }, { m: 'Apr', v: 34, display: '2.7d' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Combined Export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_METRICS: AnalyticsMetric[] = [
|
||||
...productionMetrics,
|
||||
...claimsMetrics,
|
||||
...pipelineMetrics,
|
||||
...serviceMetrics,
|
||||
]
|
||||
550
app/data/mock-claims.ts
Normal file
550
app/data/mock-claims.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
// ── Claims Management — Types & Mock Data ───────────────────────────────────
|
||||
|
||||
export type CarrierStatus =
|
||||
| 'fnol_submitted'
|
||||
| 'acknowledged'
|
||||
| 'investigation'
|
||||
| 'documentation_pending'
|
||||
| 'reserved'
|
||||
| 'negotiation'
|
||||
| 'settlement_offered'
|
||||
| 'closed'
|
||||
|
||||
export type BrokerWorkflowStatus =
|
||||
| 'waiting_carrier'
|
||||
| 'waiting_insured_docs'
|
||||
| 'needs_escalation'
|
||||
| 'client_update_overdue'
|
||||
| 'ready_to_close'
|
||||
|
||||
export type ClaimPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
export type PartyRole = 'insured' | 'adjuster' | 'carrier_contact' | 'handler' | 'attorney'
|
||||
|
||||
export interface ClaimParty {
|
||||
id: string
|
||||
role: PartyRole
|
||||
name: string
|
||||
initials: string
|
||||
email?: string
|
||||
phone?: string
|
||||
company?: string
|
||||
unreadComms: number
|
||||
}
|
||||
|
||||
export type TaskType = 'document' | 'communication' | 'escalation' | 'general'
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'overdue' | 'done'
|
||||
|
||||
export interface ClaimTask {
|
||||
id: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
assignee: string
|
||||
dueDate: string
|
||||
slaPercent: number
|
||||
type: TaskType
|
||||
isSystemSuggested?: boolean
|
||||
dismissedUntil?: string | null
|
||||
}
|
||||
|
||||
export type CommType = 'email' | 'call' | 'note' | 'system'
|
||||
|
||||
export interface ClaimCommEntry {
|
||||
id: string
|
||||
type: CommType
|
||||
partyId: string
|
||||
from: string
|
||||
to?: string
|
||||
subject?: string
|
||||
body: string
|
||||
timestamp: string
|
||||
threadId?: string
|
||||
aiDigest?: string
|
||||
}
|
||||
|
||||
export type DocCategory = 'fnol' | 'evidence' | 'estimates' | 'correspondence' | 'settlement'
|
||||
|
||||
export interface ClaimDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: DocCategory
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
size: string
|
||||
required: boolean
|
||||
received: boolean
|
||||
}
|
||||
|
||||
export type IntakeStatus = 'not_sent' | 'sent' | 'in_progress' | 'completed'
|
||||
export type GeneratedFormStatus = 'draft' | 'ready_for_signature' | 'signed' | 'submitted'
|
||||
|
||||
export interface GeneratedForm {
|
||||
id: string
|
||||
carrierFormName: string
|
||||
carrier: string
|
||||
lob: string
|
||||
status: GeneratedFormStatus
|
||||
generatedAt: string
|
||||
signedAt: string | null
|
||||
}
|
||||
|
||||
export type FinancialType = 'reserve_change' | 'payment' | 'subrogation' | 'expense'
|
||||
|
||||
export interface ClaimFinancialEntry {
|
||||
id: string
|
||||
type: FinancialType
|
||||
date: string
|
||||
amount: number
|
||||
description: string
|
||||
annotation?: string
|
||||
}
|
||||
|
||||
export interface ClaimDetail {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
policyId: string
|
||||
policyNumber: string
|
||||
carrier: string
|
||||
lob: string
|
||||
type: string
|
||||
carrierStatus: CarrierStatus
|
||||
workflowStatus: BrokerWorkflowStatus
|
||||
priority: ClaimPriority
|
||||
dateFiled: string
|
||||
daysOpen: number
|
||||
handler: string
|
||||
reservedAmount: number
|
||||
paidAmount: number
|
||||
parties: ClaimParty[]
|
||||
tasks: ClaimTask[]
|
||||
communications: ClaimCommEntry[]
|
||||
documents: ClaimDocument[]
|
||||
financials: ClaimFinancialEntry[]
|
||||
aiRecap: string
|
||||
aiRecapSourceCount: number
|
||||
keyDates: { label: string; date: string; done: boolean }[]
|
||||
reserveHistory: { date: string; amount: number; annotation: string }[]
|
||||
intakeToken: string | null
|
||||
intakeStatus: IntakeStatus
|
||||
intakeSentAt: string | null
|
||||
intakeCompletedAt: string | null
|
||||
generatedForms: GeneratedForm[]
|
||||
}
|
||||
|
||||
// ── Label Maps ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CARRIER_STATUS_LABELS: Record<CarrierStatus, string> = {
|
||||
fnol_submitted: 'FNOL Submitted',
|
||||
acknowledged: 'Acknowledged',
|
||||
investigation: 'Investigation',
|
||||
documentation_pending: 'Documentation Pending',
|
||||
reserved: 'Reserved',
|
||||
negotiation: 'Negotiation',
|
||||
settlement_offered: 'Settlement Offered',
|
||||
closed: 'Closed',
|
||||
}
|
||||
|
||||
export const WORKFLOW_STATUS_LABELS: Record<BrokerWorkflowStatus, string> = {
|
||||
waiting_carrier: 'Waiting on Carrier',
|
||||
waiting_insured_docs: 'Waiting on Insured Docs',
|
||||
needs_escalation: 'Needs Escalation',
|
||||
client_update_overdue: 'Client Update Overdue',
|
||||
ready_to_close: 'Ready to Close',
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<ClaimPriority, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
overdue: 'Overdue',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export const DOC_CATEGORY_LABELS: Record<DocCategory, string> = {
|
||||
fnol: 'FNOL & Notice',
|
||||
evidence: 'Evidence & Photos',
|
||||
estimates: 'Estimates & Appraisals',
|
||||
correspondence: 'Correspondence',
|
||||
settlement: 'Settlement',
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function fmtClaimMoney(n: number): string {
|
||||
if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return '$' + (n / 1_000).toFixed(1) + 'K'
|
||||
return '$' + n.toLocaleString()
|
||||
}
|
||||
|
||||
// ── Mock Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const clm0048: ClaimDetail = {
|
||||
id: 'CLM-0048',
|
||||
customerId: 'corp-hotel-pacifico',
|
||||
customerName: 'Hotel Pacífico S.A.',
|
||||
policyId: 'POL-2024-HP-001',
|
||||
policyNumber: 'PROP-2024-HP-001',
|
||||
carrier: 'ASSA',
|
||||
lob: 'General Risk',
|
||||
type: 'Fire damage — kitchen wing',
|
||||
carrierStatus: 'investigation',
|
||||
workflowStatus: 'waiting_carrier',
|
||||
priority: 'critical',
|
||||
dateFiled: '2026-04-05',
|
||||
daysOpen: 3,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 128_000,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Carlos Montero', initials: 'CM', email: 'cmontero@hotelpacifico.cr', phone: '+506 2643-1200', company: 'Hotel Pacífico S.A.', unreadComms: 2 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Roberto Méndez', initials: 'RM', email: 'rmendez@peritajes.cr', phone: '+506 8844-2200', company: 'Peritajes CR', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Lucía Vargas', initials: 'LV', email: 'lvargas@assa.cr', phone: '+506 2222-5000', company: 'ASSA', unreadComms: 1 },
|
||||
{ id: 'p4', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Upload police/fire report', status: 'overdue', assignee: 'Ana R.', dueDate: '2026-04-07', slaPercent: 110, type: 'document' },
|
||||
{ id: 't2', title: 'Follow up with adjuster — no site visit scheduled', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 60, type: 'communication' },
|
||||
{ id: 't3', title: 'Send initial status update to insured', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 85, type: 'communication' },
|
||||
{ id: 't4', title: 'Carrier non-response 3 days — escalate?', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 90, type: 'escalation', isSystemSuggested: true },
|
||||
{ id: 't5', title: 'Request preliminary damage estimate from adjuster', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', slaPercent: 30, type: 'general' },
|
||||
{ id: 't6', title: 'Confirm policy coverage for fire peril', status: 'done', assignee: 'Ana R.', dueDate: '2026-04-06', slaPercent: 100, type: 'general' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0048 created. FNOL submitted to ASSA.', timestamp: '2026-04-05T09:15:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Carlos Montero', to: 'Ana Ramírez', subject: 'Fire Incident — Hotel Pacífico Kitchen Wing', body: 'Ana, the fire started around 2am in the kitchen exhaust system. The fire department responded within 20 minutes. The kitchen wing sustained significant structural damage, and the adjacent dining area has smoke and water damage. We have temporarily closed the restaurant. Attached are initial photos from the scene. We need to process this claim urgently as we are losing revenue daily.', timestamp: '2026-04-05T10:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Lucía Vargas', subject: 'FNOL — Hotel Pacífico Fire Claim CLM-0048', body: 'Lucía, please find attached the FNOL for Hotel Pacífico. Fire damage to kitchen wing on April 5. Policy PROP-2024-HP-001 covers fire peril with $500K limit. Requesting immediate adjuster assignment. This is a high-value commercial client with business interruption exposure.', timestamp: '2026-04-05T11:45:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'call', partyId: 'p1', from: 'Ana Ramírez', body: 'Called Carlos Montero to confirm FNOL was submitted. Discussed initial documentation needed: fire report, photos, inventory of damaged equipment. Carlos will send equipment list by end of day. Advised to keep all receipts for temporary repairs.', timestamp: '2026-04-05T14:00:00' },
|
||||
{ id: 'c5', type: 'email', partyId: 'p3', from: 'Lucía Vargas', to: 'Ana Ramírez', subject: 'RE: FNOL — Hotel Pacífico Fire Claim CLM-0048', body: 'Ana, claim received and logged under ASSA reference FI-2026-04412. We are assigning adjuster Roberto Méndez from Peritajes CR. He will contact you to schedule site inspection.', timestamp: '2026-04-06T09:00:00', threadId: 'th2', aiDigest: 'ASSA acknowledged claim as FI-2026-04412. Adjuster Roberto Méndez (Peritajes CR) assigned. Site inspection pending scheduling.' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: FNOL Submitted → Acknowledged', timestamp: '2026-04-06T09:05:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Reviewed policy. Fire peril covered. Business interruption sublimit $200K with 48h waiting period. Need to flag BI exposure to carrier early — hotel restaurant closure = significant daily revenue loss.', timestamp: '2026-04-06T10:30:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Roberto Méndez', to: 'Ana Ramírez', subject: 'Site Inspection — Hotel Pacífico', body: 'Good morning Ana. I have been assigned to inspect the fire damage at Hotel Pacífico. Could you coordinate with the insured for access? I am available Thursday or Friday this week.', timestamp: '2026-04-07T08:00:00', threadId: 'th3' },
|
||||
{ id: 'c9', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Roberto Méndez', subject: 'RE: Site Inspection — Hotel Pacífico', body: 'Roberto, Thursday works. I will confirm with the hotel and send you the contact details. Please plan for approximately 3 hours — the damage area covers the kitchen wing and adjacent dining area.', timestamp: '2026-04-07T09:15:00', threadId: 'th3' },
|
||||
{ id: 'c10', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Acknowledged → Investigation', timestamp: '2026-04-07T10:00:00' },
|
||||
{ id: 'c11', type: 'email', partyId: 'p1', from: 'Carlos Montero', to: 'Ana Ramírez', subject: 'RE: Fire Incident — Equipment Inventory', body: 'Ana, attached is the damaged equipment inventory as requested. Total estimated replacement value approximately $85,000. Also including photos of the structural damage to the exhaust hood and ceiling. The fire inspector\'s preliminary report should be ready by Friday.', timestamp: '2026-04-07T16:00:00', threadId: 'th1', aiDigest: 'Insured provided equipment inventory ($85K estimated). Structural damage photos attached. Fire inspector report expected Friday.' },
|
||||
{ id: 'c12', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Equipment inventory received — $85K. Combined with structural estimates, total exposure could exceed $150K. May need to flag for reserve increase once adjuster report comes in. BI claim will be separate — need to start documenting daily revenue loss.', timestamp: '2026-04-08T08:30:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0048.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', size: '245 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Policy-Declarations-HP.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', size: '1.2 MB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Initial-Photos-Kitchen.zip', category: 'evidence', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-05', size: '18.4 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Equipment-Inventory.xlsx', category: 'estimates', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-07', size: '89 KB', required: true, received: true },
|
||||
{ id: 'd5', name: 'Structural-Damage-Photos.zip', category: 'evidence', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-07', size: '24.1 MB', required: false, received: true },
|
||||
{ id: 'd6', name: 'ASSA-Acknowledgment-FI202604412.pdf', category: 'correspondence', uploadedBy: 'Ana R.', uploadedAt: '2026-04-06', size: '156 KB', required: false, received: true },
|
||||
{ id: 'd7', name: 'Fire-Inspector-Report.pdf', category: 'evidence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd8', name: 'Adjuster-Preliminary-Estimate.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd9', name: 'Business-Interruption-Docs.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd10', name: 'Police-Fire-Report.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-05', amount: 128_000, description: 'Initial reserve set', annotation: 'Based on preliminary damage assessment and policy limits' },
|
||||
{ id: 'f2', type: 'expense', date: '2026-04-06', amount: 1_500, description: 'Adjuster assignment fee — Peritajes CR', annotation: 'Standard commercial property rate' },
|
||||
{ id: 'f3', type: 'expense', date: '2026-04-07', amount: 450, description: 'Emergency structural assessment', annotation: 'Required for safety clearance' },
|
||||
],
|
||||
|
||||
aiRecap: 'Siniestro por incendio en el ala de cocina del Hotel Pacífico, reportado el 5 de abril de 2026. El fuego se originó en el sistema de extracción de la cocina alrededor de las 2am. Los bomberos respondieron en 20 minutos. Daños significativos a la estructura de la cocina y daños por humo y agua en el comedor adyacente. El restaurante está cerrado temporalmente.\n\nASSA acusó recibo el 6 de abril (ref. FI-2026-04412) y asignó al ajustador Roberto Méndez de Peritajes CR. La inspección del sitio está pendiente de programar para esta semana.\n\nEl asegurado proporcionó un inventario de equipos dañados valorado en ~$85K. La exposición total podría superar $150K una vez que se complete la evaluación estructural. Hay exposición adicional por interrupción de negocio (sublímite de $200K con período de espera de 48h).\n\nPendiente: reporte del inspector de bomberos (esperado viernes), estimación preliminar del ajustador, documentación de pérdida de ingresos diarios para reclamo de BI.',
|
||||
aiRecapSourceCount: 14,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'FNOL Filed', date: '2026-04-05', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-04-06', done: true },
|
||||
{ label: 'Adjuster Assigned', date: '2026-04-06', done: true },
|
||||
{ label: 'Investigation Started', date: '2026-04-07', done: true },
|
||||
{ label: 'Site Inspection', date: '2026-04-10', done: false },
|
||||
{ label: 'Preliminary Estimate', date: '', done: false },
|
||||
{ label: 'Reserve Review', date: '', done: false },
|
||||
{ label: 'Settlement', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-05', amount: 128_000, annotation: 'Initial reserve — fire damage assessment pending' },
|
||||
],
|
||||
intakeToken: 'tk_hp_048_a3f1',
|
||||
intakeStatus: 'completed',
|
||||
intakeSentAt: '2026-04-05T14:30:00Z',
|
||||
intakeCompletedAt: '2026-04-06T09:12:00Z',
|
||||
generatedForms: [
|
||||
{ id: 'gf-048-1', carrierFormName: 'Aviso de Pérdida — ASSA', carrier: 'ASSA', lob: 'General Risk', status: 'ready_for_signature', generatedAt: '2026-04-06T10:00:00Z', signedAt: null },
|
||||
],
|
||||
}
|
||||
|
||||
const clm0047: ClaimDetail = {
|
||||
id: 'CLM-0047',
|
||||
customerId: 'corp-empresa-abc',
|
||||
customerName: 'Empresa ABC S.A.',
|
||||
policyId: 'POL-2024-ABC-FLEET',
|
||||
policyNumber: 'AUTO-2024-FLEET-007',
|
||||
carrier: 'Qualitas',
|
||||
lob: 'Auto',
|
||||
type: 'Auto collision — fleet vehicle',
|
||||
carrierStatus: 'documentation_pending',
|
||||
workflowStatus: 'waiting_insured_docs',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-04-03',
|
||||
daysOpen: 5,
|
||||
handler: 'Marco V.',
|
||||
reservedAmount: 14_200,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Fernando Solano', initials: 'FS', email: 'fsolano@empresaabc.cr', phone: '+506 2255-8800', company: 'Empresa ABC S.A.', unreadComms: 0 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Patricia Ulate', initials: 'PU', email: 'pulate@qualitas.cr', phone: '+506 2233-4400', company: 'Qualitas', unreadComms: 1 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Diego Mora', initials: 'DM', email: 'dmora@qualitas.cr', phone: '+506 2233-4401', company: 'Qualitas', unreadComms: 0 },
|
||||
{ id: 'p4', role: 'handler', name: 'Marco Vargas', initials: 'MV', email: 'marco.v@seguros.cr', phone: '+506 8866-4400', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Obtain police report from insured', status: 'overdue', assignee: 'Marco V.', dueDate: '2026-04-06', slaPercent: 120, type: 'document' },
|
||||
{ id: 't2', title: 'Submit repair estimates to carrier', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-10', slaPercent: 50, type: 'document' },
|
||||
{ id: 't3', title: 'Confirm driver was authorized fleet operator', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-09', slaPercent: 65, type: 'general' },
|
||||
{ id: 't4', title: 'Send client status update — 5 days no communication', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-08', slaPercent: 92, type: 'communication', isSystemSuggested: true },
|
||||
{ id: 't5', title: 'Request adjuster photos from body shop', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-05', slaPercent: 100, type: 'general' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0047 created. FNOL submitted to Qualitas.', timestamp: '2026-04-03T08:30:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Fernando Solano', to: 'Marco Vargas', subject: 'Fleet Vehicle Accident — Unit 07', body: 'Marco, one of our delivery trucks (Unit 07, plates SJO-7744) was involved in a collision yesterday on Ruta 27 near Escazú. The driver (José Mora) is fine but the front end is heavily damaged. The vehicle was towed to Taller Central in La Uruca. Police were called and a report was filed.', timestamp: '2026-04-03T09:00:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Marco Vargas', to: 'Diego Mora', subject: 'FNOL — Empresa ABC Fleet Collision CLM-0047', body: 'Diego, submitting FNOL for fleet collision. Policy AUTO-2024-FLEET-007, Unit 07 (SJO-7744). Collision on Ruta 27, 2 April. Vehicle at Taller Central, La Uruca. Police report filed. Requesting adjuster assignment.', timestamp: '2026-04-03T10:15:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p3', from: 'Diego Mora', to: 'Marco Vargas', subject: 'RE: FNOL — Empresa ABC Fleet Collision', body: 'Marco, claim received under Qualitas ref QAC-2026-1182. Adjuster Patricia Ulate will handle. She will coordinate directly with the body shop for inspection. Please provide the police report and driver authorization docs at your earliest convenience.', timestamp: '2026-04-04T08:00:00', threadId: 'th2', aiDigest: 'Qualitas acknowledged as QAC-2026-1182. Adjuster Patricia Ulate assigned. Requesting police report and driver authorization docs.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p2', from: 'Marco Vargas', body: 'Called Patricia Ulate. She confirmed she will visit Taller Central on Monday for inspection. Needs police report before she can proceed with estimate. Estimated repair range $12K–$16K based on initial description.', timestamp: '2026-04-04T14:30:00' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Acknowledged → Documentation Pending', timestamp: '2026-04-05T09:00:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p4', from: 'Marco Vargas', body: 'Insured has not yet sent police report. Called Fernando twice, went to voicemail. Will try again tomorrow. Fleet policy requires authorized driver confirmation — need to get signed driver roster from HR department.', timestamp: '2026-04-06T16:00:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Patricia Ulate', to: 'Marco Vargas', subject: 'Body Shop Photos — Unit 07', body: 'Marco, I visited Taller Central this morning. Photos attached. Front bumper, hood, radiator, and right fender all need replacement. Frame appears straight — no structural damage. Preliminary estimate pending receipt of police report to confirm fault assignment.', timestamp: '2026-04-07T11:00:00', threadId: 'th3', aiDigest: 'Adjuster inspected vehicle. Damage: bumper, hood, radiator, right fender. No structural damage. Estimate pending police report for fault assignment.' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0047.pdf', category: 'fnol', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', size: '198 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Fleet-Policy-Declarations.pdf', category: 'fnol', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', size: '890 KB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Accident-Scene-Photos.zip', category: 'evidence', uploadedBy: 'Fernando Solano', uploadedAt: '2026-04-03', size: '12.6 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Body-Shop-Inspection-Photos.zip', category: 'evidence', uploadedBy: 'Patricia Ulate', uploadedAt: '2026-04-07', size: '8.9 MB', required: false, received: true },
|
||||
{ id: 'd5', name: 'Police-Report.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd6', name: 'Driver-Authorization-Roster.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd7', name: 'Repair-Estimate-TallerCentral.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-03', amount: 14_200, description: 'Initial reserve set', annotation: 'Based on typical fleet collision range' },
|
||||
{ id: 'f2', type: 'expense', date: '2026-04-03', amount: 85, description: 'Towing — Ruta 27 to Taller Central' },
|
||||
],
|
||||
|
||||
aiRecap: 'Colisión de vehículo de flota (Unidad 07, SJO-7744) de Empresa ABC en Ruta 27 cerca de Escazú el 2 de abril. El conductor José Mora no resultó herido. El vehículo fue remolcado a Taller Central en La Uruca.\n\nQualitas acusó recibo (ref QAC-2026-1182) y asignó a la ajustadora Patricia Ulate. Ella inspeccionó el vehículo el 7 de abril — daños en bumper, capó, radiador y guardafango derecho. Sin daño estructural. Estimación pendiente del reporte policial para determinar culpa.\n\nBloqueadores: el asegurado no ha proporcionado el reporte policial (vencido) ni la documentación de autorización del conductor. Se han hecho múltiples intentos de contacto sin respuesta.',
|
||||
aiRecapSourceCount: 9,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'FNOL Filed', date: '2026-04-03', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-04-04', done: true },
|
||||
{ label: 'Adjuster Assigned', date: '2026-04-04', done: true },
|
||||
{ label: 'Vehicle Inspection', date: '2026-04-07', done: true },
|
||||
{ label: 'Police Report Due', date: '2026-04-06', done: false },
|
||||
{ label: 'Repair Estimate', date: '', done: false },
|
||||
{ label: 'Settlement', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-03', amount: 14_200, annotation: 'Initial reserve — typical fleet collision' },
|
||||
],
|
||||
intakeToken: 'tk_abc_047_b7e2',
|
||||
intakeStatus: 'in_progress',
|
||||
intakeSentAt: '2026-04-04T11:00:00Z',
|
||||
intakeCompletedAt: null,
|
||||
generatedForms: [],
|
||||
}
|
||||
|
||||
const clm0043: ClaimDetail = {
|
||||
id: 'CLM-0043',
|
||||
customerId: 'corp-supermercado-tico',
|
||||
customerName: 'Supermercado Tico S.A.',
|
||||
policyId: 'POL-2023-ST-GL',
|
||||
policyNumber: 'GL-2023-ST-001',
|
||||
carrier: 'INS',
|
||||
lob: 'General Risk',
|
||||
type: 'Liability — customer injury in store',
|
||||
carrierStatus: 'negotiation',
|
||||
workflowStatus: 'client_update_overdue',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-03-17',
|
||||
daysOpen: 22,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 45_000,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Jorge Calvo', initials: 'JC', email: 'jcalvo@supertico.cr', phone: '+506 2244-9900', company: 'Supermercado Tico S.A.', unreadComms: 3 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Sandra Pérez', initials: 'SP', email: 'sperez@ins.go.cr', phone: '+506 2287-6600', company: 'INS', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Miguel Hernández', initials: 'MH', email: 'mhernandez@ins.go.cr', phone: '+506 2287-6601', company: 'INS', unreadComms: 0 },
|
||||
{ id: 'p4', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
{ id: 'p5', role: 'attorney', name: 'Lic. Gabriela Rojas', initials: 'GR', email: 'grojas@bufeterojas.cr', phone: '+506 2255-1100', company: 'Bufete Rojas & Asociados', unreadComms: 1 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Send client status update — 8 days overdue', status: 'overdue', assignee: 'Ana R.', dueDate: '2026-03-31', slaPercent: 140, type: 'communication' },
|
||||
{ id: 't2', title: 'Review settlement offer from INS — $38K proposed', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-10', slaPercent: 55, type: 'general' },
|
||||
{ id: 't3', title: 'Coordinate with attorney on counter-offer strategy', status: 'in_progress', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 70, type: 'communication' },
|
||||
{ id: 't4', title: 'Upload updated medical records from claimant', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-11', slaPercent: 40, type: 'document' },
|
||||
{ id: 't5', title: 'Client update overdue — escalate?', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 100, type: 'escalation', isSystemSuggested: true },
|
||||
{ id: 't6', title: 'File FNOL with carrier', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-17', slaPercent: 100, type: 'general' },
|
||||
{ id: 't7', title: 'Obtain incident report from store manager', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-19', slaPercent: 100, type: 'document' },
|
||||
{ id: 't8', title: 'Upload CCTV footage', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-22', slaPercent: 100, type: 'document' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0043 created. FNOL submitted to INS.', timestamp: '2026-03-17T10:00:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Jorge Calvo', to: 'Ana Ramírez', subject: 'Customer Slip and Fall — Produce Section', body: 'Ana, we had an incident on March 16. A customer (Marta Solís) slipped on a wet floor in the produce section and sustained a hip injury. She was taken to Hospital CIMA by ambulance. She has retained an attorney. We have CCTV footage and the incident report from our store manager.', timestamp: '2026-03-17T10:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Miguel Hernández', subject: 'FNOL — Supermercado Tico Liability Claim CLM-0043', body: 'Miguel, submitting FNOL for a general liability claim. Customer slip and fall injury in the produce section. Incident March 16. Claimant retained attorney. We have CCTV footage and incident report. Policy GL-2023-ST-001 with $1M per-occurrence limit.', timestamp: '2026-03-17T11:30:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p3', from: 'Miguel Hernández', to: 'Ana Ramírez', subject: 'RE: FNOL — Supermercado Tico Liability', body: 'Ana, claim acknowledged under INS ref LB-2026-0388. Sandra Pérez assigned as adjuster. Please forward CCTV footage and incident report. Given attorney involvement, we are fast-tracking investigation.', timestamp: '2026-03-18T09:00:00', threadId: 'th2', aiDigest: 'INS acknowledged claim as LB-2026-0388. Adjuster Sandra Pérez assigned. Fast-tracking due to attorney involvement. Requesting CCTV and incident report.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p5', from: 'Lic. Gabriela Rojas', body: 'Incoming call from claimant\'s attorney. Informed that Marta Solís underwent hip surgery. Medical expenses to date approximately $28K. Attorney indicated client is seeking $50K total including pain and suffering. Requested we expedite the claim process.', timestamp: '2026-03-20T14:00:00' },
|
||||
{ id: 'c6', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Attorney demanding $50K. Medical expenses $28K. Need to review CCTV carefully — if the wet floor sign was properly placed, liability may be disputed. Sent CCTV to INS adjuster for review.', timestamp: '2026-03-20T15:30:00' },
|
||||
{ id: 'c7', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Investigation → Reserved. Reserve set at $45,000.', timestamp: '2026-03-25T09:00:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Sandra Pérez', to: 'Ana Ramírez', subject: 'Investigation Update — CLM-0043', body: 'Ana, we reviewed the CCTV footage. The wet floor sign was visible but positioned slightly away from the actual wet area. This creates partial liability exposure. We are setting reserve at $45K. Our recommendation is to negotiate settlement in the $35K–$40K range to avoid litigation costs.', timestamp: '2026-03-25T10:00:00', threadId: 'th4', aiDigest: 'INS reviewed CCTV. Wet floor sign was present but mispositioned — partial liability. Reserve set $45K. Recommends settling $35K–$40K to avoid litigation costs.' },
|
||||
{ id: 'c9', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Jorge Calvo', subject: 'Claim Update — Liability Assessment', body: 'Jorge, INS has completed their initial investigation. The CCTV review indicates the wet floor sign, while present, was not optimally positioned. This creates some liability exposure. INS is recommending a negotiated settlement. I will discuss strategy with you once we have a formal offer from the carrier. Please call me at your convenience to discuss.', timestamp: '2026-03-25T14:00:00', threadId: 'th5' },
|
||||
{ id: 'c10', type: 'email', partyId: 'p3', from: 'Miguel Hernández', to: 'Ana Ramírez', subject: 'Settlement Offer — CLM-0043', body: 'Ana, INS is prepared to offer $38,000 to settle this claim. This includes medical expenses ($28K) plus $10K for pain and suffering. Please communicate this to the insured and the claimant\'s attorney. We believe this is a fair offer given the shared liability circumstances.', timestamp: '2026-04-01T09:00:00', threadId: 'th6', aiDigest: 'INS offering $38K settlement ($28K medical + $10K pain/suffering). Considers shared liability. Awaiting broker/attorney response.' },
|
||||
{ id: 'c11', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Reserved → Negotiation', timestamp: '2026-04-01T09:05:00' },
|
||||
{ id: 'c12', type: 'call', partyId: 'p5', from: 'Ana Ramírez', body: 'Called attorney Gabriela Rojas to discuss $38K offer. Attorney says client won\'t accept less than $45K. Agreed to prepare counter-proposal for $42K based on additional medical documentation showing ongoing rehabilitation needs.', timestamp: '2026-04-02T11:00:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0043.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-17', size: '212 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'GL-Policy-Declarations.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-17', size: '1.1 MB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Incident-Report-StoreManager.pdf', category: 'evidence', uploadedBy: 'Jorge Calvo', uploadedAt: '2026-03-18', size: '340 KB', required: true, received: true },
|
||||
{ id: 'd4', name: 'CCTV-Footage-ProduceSection.mp4', category: 'evidence', uploadedBy: 'Jorge Calvo', uploadedAt: '2026-03-19', size: '245 MB', required: true, received: true },
|
||||
{ id: 'd5', name: 'Medical-Records-MartaSolis.pdf', category: 'evidence', uploadedBy: 'Lic. Gabriela Rojas', uploadedAt: '2026-03-21', size: '4.2 MB', required: true, received: true },
|
||||
{ id: 'd6', name: 'INS-Investigation-Report.pdf', category: 'correspondence', uploadedBy: 'Sandra Pérez', uploadedAt: '2026-03-25', size: '890 KB', required: false, received: true },
|
||||
{ id: 'd7', name: 'Settlement-Offer-38K.pdf', category: 'settlement', uploadedBy: 'Ana R.', uploadedAt: '2026-04-01', size: '156 KB', required: false, received: true },
|
||||
{ id: 'd8', name: 'Updated-Medical-Records.pdf', category: 'evidence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd9', name: 'Counter-Offer-Response.pdf', category: 'settlement', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd10', name: 'Signed-Release-Form.pdf', category: 'settlement', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-03-17', amount: 30_000, description: 'Initial reserve set', annotation: 'Preliminary — slip and fall with attorney involvement' },
|
||||
{ id: 'f2', type: 'reserve_change', date: '2026-03-25', amount: 45_000, description: 'Reserve increased', annotation: 'CCTV review shows partial liability — increased from $30K' },
|
||||
{ id: 'f3', type: 'expense', date: '2026-03-18', amount: 200, description: 'Incident scene documentation' },
|
||||
{ id: 'f4', type: 'expense', date: '2026-03-25', amount: 750, description: 'Legal consultation — liability assessment' },
|
||||
],
|
||||
|
||||
aiRecap: 'Reclamo de responsabilidad civil por caída de cliente (Marta Solís) en la sección de productos del Supermercado Tico el 16 de marzo. La clienta sufrió una lesión de cadera que requirió cirugía. Ha contratado abogada (Lic. Gabriela Rojas).\n\nINS completó la investigación el 25 de marzo. La revisión del CCTV muestra que el letrero de piso mojado estaba presente pero mal posicionado — esto crea responsabilidad parcial. La reserva se incrementó de $30K a $45K.\n\nINS ofreció $38K para liquidar ($28K gastos médicos + $10K dolor y sufrimiento). La abogada de la reclamante rechazó — exige mínimo $45K. Se está preparando contraoferta de $42K basada en documentación médica adicional que muestra necesidades de rehabilitación continua.\n\nACCIÓN REQUERIDA: Actualización al cliente pendiente hace 8 días. El asegurado (Jorge Calvo) no ha sido informado del estado de la negociación.',
|
||||
aiRecapSourceCount: 18,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'Incident Date', date: '2026-03-16', done: true },
|
||||
{ label: 'FNOL Filed', date: '2026-03-17', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-03-18', done: true },
|
||||
{ label: 'Investigation Complete', date: '2026-03-25', done: true },
|
||||
{ label: 'Reserve Set ($45K)', date: '2026-03-25', done: true },
|
||||
{ label: 'Settlement Offered ($38K)', date: '2026-04-01', done: true },
|
||||
{ label: 'Counter-Offer Deadline', date: '2026-04-10', done: false },
|
||||
{ label: 'Settlement or Litigation', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-03-17', amount: 30_000, annotation: 'Initial reserve — slip and fall with legal representation' },
|
||||
{ date: '2026-03-25', amount: 45_000, annotation: 'Increased after CCTV review — partial liability exposure confirmed' },
|
||||
],
|
||||
intakeToken: 'tk_st_043_c9d4',
|
||||
intakeStatus: 'completed',
|
||||
intakeSentAt: '2026-03-17T16:00:00Z',
|
||||
intakeCompletedAt: '2026-03-18T08:45:00Z',
|
||||
generatedForms: [
|
||||
{ id: 'gf-043-1', carrierFormName: 'Aviso de Pérdida — Mapfre', carrier: 'Mapfre', lob: 'General Risk', status: 'submitted', generatedAt: '2026-03-18T10:00:00Z', signedAt: '2026-03-19T14:30:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const clm0045: ClaimDetail = {
|
||||
id: 'CLM-0045',
|
||||
customerId: 'corp-clinica-sanjose',
|
||||
customerName: 'Clínica San José',
|
||||
policyId: 'POL-2024-CSJ-LIFE',
|
||||
policyNumber: 'LIFE-2024-CSJ-001',
|
||||
carrier: 'Pan-American Life',
|
||||
lob: 'Life',
|
||||
type: 'Surgery pre-authorization',
|
||||
carrierStatus: 'reserved',
|
||||
workflowStatus: 'waiting_carrier',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-03-27',
|
||||
daysOpen: 12,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 23_500,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Dr. Ricardo Blanco', initials: 'RB', email: 'rblanco@clinicasj.cr', phone: '+506 2290-1500', company: 'Clínica San José', unreadComms: 0 },
|
||||
{ id: 'p2', role: 'carrier_contact', name: 'Elena Cordero', initials: 'EC', email: 'ecordero@palig.com', phone: '+506 2201-3300', company: 'Pan-American Life', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Follow up on pre-authorization decision — 5 days pending', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 78, type: 'communication' },
|
||||
{ id: 't2', title: 'Confirm surgical facility is in-network', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-29', slaPercent: 100, type: 'general' },
|
||||
{ id: 't3', title: 'Upload specialist referral letter', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-30', slaPercent: 100, type: 'document' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p3', from: 'System', body: 'Claim CLM-0045 created. Pre-authorization request submitted to Pan-American Life.', timestamp: '2026-03-27T09:00:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Dr. Ricardo Blanco', to: 'Ana Ramírez', subject: 'Surgery Pre-Auth Request — Group Policy', body: 'Ana, we need pre-authorization for knee replacement surgery for one of our covered employees (María del Carmen Vega, DOB 15/07/1968). The procedure is scheduled for April 15 at Hospital Metropolitano. Attached are the specialist referral, diagnostic imaging, and treatment plan.', timestamp: '2026-03-27T09:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p3', from: 'Ana Ramírez', to: 'Elena Cordero', subject: 'Pre-Auth Request — Clínica San José Group CLM-0045', body: 'Elena, submitting pre-authorization for knee replacement surgery under group policy LIFE-2024-CSJ-001. Patient María del Carmen Vega. Surgery scheduled April 15 at Hospital Metropolitano (in-network confirmed). All supporting documentation attached.', timestamp: '2026-03-27T11:00:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p2', from: 'Elena Cordero', to: 'Ana Ramírez', subject: 'RE: Pre-Auth Request — Clínica San José Group', body: 'Ana, request received and under medical review. Reference PA-2026-0455. Standard review period is 5-7 business days. We may request additional documentation if needed.', timestamp: '2026-03-28T10:00:00', threadId: 'th2', aiDigest: 'Pan-American Life acknowledged pre-auth request as PA-2026-0455. Under medical review. 5-7 business day standard review period.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p1', from: 'Ana Ramírez', body: 'Called Dr. Blanco to confirm submission. Advised that standard review is 5-7 business days. He is concerned about timing — surgery scheduled April 15 and patient has been waiting 3 months. Will escalate if no response by April 4.', timestamp: '2026-03-28T14:00:00' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p3', from: 'System', body: 'Carrier status updated: Documentation Pending → Reserved. Reserve set at $23,500.', timestamp: '2026-04-02T09:00:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p3', from: 'Ana Ramírez', body: 'Reserve set at $23,500 — this is the estimated surgery cost. No decision yet on pre-authorization. 5 business days have passed. Will follow up with Elena tomorrow if no response.', timestamp: '2026-04-03T16:00:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'PreAuth-Request-CLM0045.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-27', size: '178 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Specialist-Referral-Letter.pdf', category: 'fnol', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '95 KB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Diagnostic-Imaging-KneeMRI.pdf', category: 'evidence', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '15.2 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Treatment-Plan.pdf', category: 'evidence', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '240 KB', required: true, received: true },
|
||||
{ id: 'd5', name: 'PALIG-Acknowledgment.pdf', category: 'correspondence', uploadedBy: 'Ana R.', uploadedAt: '2026-03-28', size: '112 KB', required: false, received: true },
|
||||
{ id: 'd6', name: 'PreAuth-Decision-Letter.pdf', category: 'correspondence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-02', amount: 23_500, description: 'Reserve set — estimated surgery cost', annotation: 'Knee replacement at Hospital Metropolitano' },
|
||||
],
|
||||
|
||||
aiRecap: 'Solicitud de preautorización para cirugía de reemplazo de rodilla para empleada María del Carmen Vega bajo la póliza grupal de Clínica San José. Cirugía programada para el 15 de abril en Hospital Metropolitano (red confirmada).\n\nPan-American Life acusó recibo el 28 de marzo (ref PA-2026-0455). Período de revisión estándar 5-7 días hábiles. La reserva se estableció en $23,500 el 2 de abril.\n\nHan pasado 7 días hábiles sin decisión. El Dr. Blanco está preocupado por el cronograma — la paciente ha esperado 3 meses. Se necesita seguimiento urgente con la aseguradora para obtener decisión antes de la fecha de cirugía.',
|
||||
aiRecapSourceCount: 8,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'Pre-Auth Submitted', date: '2026-03-27', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-03-28', done: true },
|
||||
{ label: 'Review Period Ends', date: '2026-04-07', done: false },
|
||||
{ label: 'Decision Expected', date: '2026-04-09', done: false },
|
||||
{ label: 'Surgery Scheduled', date: '2026-04-15', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-02', amount: 23_500, annotation: 'Reserve set — estimated surgery cost at Hospital Metropolitano' },
|
||||
],
|
||||
intakeToken: null,
|
||||
intakeStatus: 'not_sent',
|
||||
intakeSentAt: null,
|
||||
intakeCompletedAt: null,
|
||||
generatedForms: [],
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_CLAIM_DETAILS: Record<string, ClaimDetail> = {
|
||||
'CLM-0048': clm0048,
|
||||
'CLM-0047': clm0047,
|
||||
'CLM-0043': clm0043,
|
||||
'CLM-0045': clm0045,
|
||||
}
|
||||
461
app/data/mock-customers.ts
Normal file
461
app/data/mock-customers.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Mock customer data for visual design & demo.
|
||||
* Each client has realistic policies, claims, payments, and activity.
|
||||
*/
|
||||
|
||||
export type MockPolicy = {
|
||||
id: string
|
||||
line: string
|
||||
carrier: string
|
||||
product: string
|
||||
premium: number
|
||||
status: 'Active' | 'Pending' | 'Lapsed' | 'Cancelled'
|
||||
renewal: string
|
||||
icon: string
|
||||
details?: string
|
||||
referralChannel?: string
|
||||
}
|
||||
|
||||
export type MockClaim = {
|
||||
id: string
|
||||
policy: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
status: 'In progress' | 'Resolved' | 'Denied' | 'Under review'
|
||||
}
|
||||
|
||||
export type MockPayment = {
|
||||
date: string
|
||||
amount: number
|
||||
policy: string
|
||||
method: string
|
||||
status: 'Paid' | 'Pending' | 'Overdue' | 'Failed'
|
||||
}
|
||||
|
||||
export type MockActivityEvent = {
|
||||
date: string
|
||||
text: string
|
||||
type: 'claim' | 'payment' | 'renewal' | 'quote' | 'note' | 'policy' | 'onboarding'
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer tier — derived from data completeness & policy status:
|
||||
* quick_lead — minimal capture (name + phone/email only)
|
||||
* lead — profile info but no policies yet
|
||||
* customer — has at least one active policy
|
||||
* cancelled — had policies but all cancelled / lapsed
|
||||
*/
|
||||
export type CustomerTier = 'quick_lead' | 'lead' | 'customer' | 'cancelled'
|
||||
|
||||
export type MockCustomer = {
|
||||
id: string
|
||||
name: string
|
||||
initials: string
|
||||
type: 'Individual' | 'Corporate'
|
||||
documentId: string
|
||||
email: string
|
||||
phone: string
|
||||
birthDate: string
|
||||
gender: string
|
||||
address: string
|
||||
since: string
|
||||
agent: string
|
||||
preferredLang: string
|
||||
tags: string[]
|
||||
policies: MockPolicy[]
|
||||
claims: MockClaim[]
|
||||
payments: MockPayment[]
|
||||
activity: MockActivityEvent[]
|
||||
paymentStatus: 'Current' | 'Overdue' | 'Grace period' | 'N/A'
|
||||
}
|
||||
|
||||
/** Derive tier from customer data */
|
||||
export function customerTier(c: MockCustomer): CustomerTier {
|
||||
if (c.policies.length === 0 && (!c.documentId || c.documentId === '—') && !c.address) return 'quick_lead'
|
||||
if (c.policies.length === 0) return 'lead'
|
||||
const hasActive = c.policies.some(p => p.status === 'Active' || p.status === 'Pending')
|
||||
if (!hasActive) return 'cancelled'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 1 · María Elena Pérez Solano */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const maria: MockCustomer = {
|
||||
id: 'cust-001',
|
||||
name: 'María Elena Pérez Solano',
|
||||
initials: 'MP',
|
||||
type: 'Individual',
|
||||
documentId: '1-0456-0812',
|
||||
email: 'maria.perez@email.com',
|
||||
phone: '+506 8834-2291',
|
||||
birthDate: '1988-03-14',
|
||||
gender: 'Female',
|
||||
address: 'San José, Escazú, Trejos Montealegre',
|
||||
since: '2021-06-10',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['VIP', 'Referral source'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2024-4412', line: 'Auto', carrier: 'ASSA', product: '2023 Toyota RAV4 — SJO-4412', premium: 1840, status: 'Active', renewal: '2025-06-15', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2024-7788', line: 'Life', carrier: 'INS', product: 'Individual health plan', premium: 3200, status: 'Active', renewal: '2025-09-01', icon: 'i-heroicons-heart', details: 'Gold tier • Dental included', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2023-1190', line: 'Life', carrier: 'Mapfre', product: 'Term life — 20yr, $250K', premium: 960, status: 'Active', renewal: '2026-01-10', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Carlos Pérez (spouse)', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-2891', policy: 'POL-2024-4412', type: 'Collision', date: '2025-02-18', amount: 4200, status: 'In progress' },
|
||||
{ id: 'CL-2204', policy: 'POL-2024-7788', type: 'Medical reimbursement', date: '2024-08-05', amount: 1100, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 540, policy: 'POL-2024-4412', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 267, policy: 'POL-2024-7788', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 540, policy: 'POL-2024-4412', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 267, policy: 'POL-2024-7788', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 80, policy: 'POL-2023-1190', method: 'Transfer', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'Auto claim CL-2891 — adjuster report uploaded', type: 'claim' },
|
||||
{ date: 'Yesterday', text: 'Health premium payment confirmed ($267)', type: 'payment' },
|
||||
{ date: 'Mar 28', text: 'Renewal notice sent for Auto policy', type: 'renewal' },
|
||||
{ date: 'Mar 15', text: 'Quote requested: Home insurance', type: 'quote' },
|
||||
{ date: 'Feb 18', text: 'Collision claim filed — CL-2891', type: 'claim' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 2 · Roberto Jiménez Mora */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const roberto: MockCustomer = {
|
||||
id: 'cust-002',
|
||||
name: 'Roberto Jiménez Mora',
|
||||
initials: 'RJ',
|
||||
type: 'Individual',
|
||||
documentId: '3-0321-0654',
|
||||
email: 'roberto.jimenez@correo.cr',
|
||||
phone: '+506 7012-8845',
|
||||
birthDate: '1975-11-22',
|
||||
gender: 'Male',
|
||||
address: 'Heredia, Belén, La Asunción',
|
||||
since: '2019-02-15',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Long-term client'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2023-3301', line: 'Auto', carrier: 'Qualitas', product: '2021 Hyundai Tucson — HER-9901', premium: 1520, status: 'Active', renewal: '2025-08-20', icon: 'i-heroicons-truck', details: 'Comprehensive • $750 deductible', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2023-3302', line: 'Auto', carrier: 'Qualitas', product: '2019 Honda CRV — HER-6632', premium: 1280, status: 'Active', renewal: '2025-08-20', icon: 'i-heroicons-truck', details: 'Comprehensive • $750 deductible • Spouse vehicle', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2022-1010', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Belén residence', premium: 890, status: 'Active', renewal: '2025-11-01', icon: 'i-heroicons-home-modern', details: 'Dwelling $185K • Contents $40K • Earthquake included', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2024-5500', line: 'Life', carrier: 'Pan-American Life', product: 'Whole life — $150K', premium: 1440, status: 'Active', renewal: '2026-02-15', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Lucía Jiménez (spouse)', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2024-5501', line: 'General Risk', carrier: 'INS', product: 'Personal umbrella — $1M', premium: 420, status: 'Active', renewal: '2025-12-01', icon: 'i-heroicons-shield-exclamation', details: 'Excess liability over auto + home', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-1840', policy: 'POL-2022-1010', type: 'Water damage', date: '2024-04-12', amount: 6800, status: 'Resolved' },
|
||||
{ id: 'CL-2105', policy: 'POL-2023-3301', type: 'Windshield', date: '2024-11-28', amount: 450, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 233, policy: 'POL-2023-3301', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 197, policy: 'POL-2023-3302', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 74, policy: 'POL-2022-1010', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 120, policy: 'POL-2024-5500', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 233, policy: 'POL-2023-3301', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 197, policy: 'POL-2023-3302', method: 'Auto-debit', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Yesterday', text: 'Monthly premium auto-debited ($624)', type: 'payment' },
|
||||
{ date: 'Mar 30', text: 'Umbrella policy annual review scheduled', type: 'renewal' },
|
||||
{ date: 'Mar 15', text: 'Quote requested: Teen driver add-on', type: 'quote' },
|
||||
{ date: 'Feb 20', text: 'Home policy endorsement — added jewelry rider', type: 'policy' },
|
||||
{ date: 'Jan 10', text: 'Windshield claim CL-2105 resolved ($450)', type: 'claim' },
|
||||
{ date: 'Dec 15', text: 'Year-end portfolio review completed', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 3 · Carolina Fallas Vargas */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const carolina: MockCustomer = {
|
||||
id: 'cust-003',
|
||||
name: 'Carolina Fallas Vargas',
|
||||
initials: 'CF',
|
||||
type: 'Individual',
|
||||
documentId: '2-0589-0177',
|
||||
email: 'carolina.fallas@gmail.com',
|
||||
phone: '+506 6198-3340',
|
||||
birthDate: '1992-07-30',
|
||||
gender: 'Female',
|
||||
address: 'Cartago, Paraíso, Orosí',
|
||||
since: '2023-09-05',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['New client'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2024-8810', line: 'Auto', carrier: 'INS', product: '2024 Kia Sportage — CAR-1177', premium: 1650, status: 'Active', renewal: '2025-09-05', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Facebook campaign' },
|
||||
{ id: 'POL-2024-8811', line: 'Home', carrier: 'ASSA', product: "Renter's insurance — Paraíso apt", premium: 320, status: 'Active', renewal: '2025-09-05', icon: 'i-heroicons-home-modern', details: 'Contents $25K • Liability $100K', referralChannel: 'Facebook campaign' },
|
||||
],
|
||||
claims: [],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 138, policy: 'POL-2024-8810', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 27, policy: 'POL-2024-8811', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 138, policy: 'POL-2024-8810', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 27, policy: 'POL-2024-8811', method: 'Credit card', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Mar 25', text: 'Monthly payment processed ($165)', type: 'payment' },
|
||||
{ date: 'Mar 10', text: 'Renter policy — updated inventory list', type: 'policy' },
|
||||
{ date: 'Feb 05', text: 'Welcome call completed by Marco V.', type: 'onboarding' },
|
||||
{ date: 'Sep 05', text: 'Policies issued — Auto + Renter', type: 'policy' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 4 · Luis Andrés Solís Calderón */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const luis: MockCustomer = {
|
||||
id: 'cust-004',
|
||||
name: 'Luis Andrés Solís Calderón',
|
||||
initials: 'LS',
|
||||
type: 'Individual',
|
||||
documentId: '1-1102-0398',
|
||||
email: 'luis.solis@outlook.com',
|
||||
phone: '+506 8455-7721',
|
||||
birthDate: '1968-01-09',
|
||||
gender: 'Male',
|
||||
address: 'San José, Santa Ana, Pozos',
|
||||
since: '2017-11-20',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['High-value', 'Referral source', 'Multi-line'],
|
||||
paymentStatus: 'Overdue',
|
||||
policies: [
|
||||
{ id: 'POL-2022-2200', line: 'Auto', carrier: 'Qualitas', product: '2022 BMW X5 — SJO-2200', premium: 3200, status: 'Active', renewal: '2025-05-01', icon: 'i-heroicons-truck', details: 'Comprehensive • $1,000 deductible', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2022-2201', line: 'Auto', carrier: 'Qualitas', product: '2020 Mercedes GLC — SJO-7788', premium: 2800, status: 'Active', renewal: '2025-05-01', icon: 'i-heroicons-truck', details: 'Comprehensive • $1,000 deductible • Spouse vehicle', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2020-0055', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Santa Ana residence', premium: 2100, status: 'Active', renewal: '2025-07-15', icon: 'i-heroicons-home-modern', details: 'Dwelling $420K • Contents $95K • Flood + Earthquake', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2021-0750', line: 'Life', carrier: 'Pan-American Life', product: 'Whole life — $500K', premium: 4800, status: 'Active', renewal: '2025-11-20', icon: 'i-heroicons-shield-check', details: 'Beneficiaries: Patricia Calderón (60%), Children (40%)', referralChannel: 'Google Ads' },
|
||||
{ id: 'POL-2023-6600', line: 'Life', carrier: 'Blue Cross', product: 'Family health — Platinum', premium: 8400, status: 'Active', renewal: '2025-10-01', icon: 'i-heroicons-heart', details: '4 members • Dental + Vision • Intl coverage', referralChannel: 'Google Ads' },
|
||||
{ id: 'POL-2023-6601', line: 'General Risk', carrier: 'INS', product: 'Personal umbrella — $2M', premium: 680, status: 'Active', renewal: '2025-12-01', icon: 'i-heroicons-shield-exclamation', details: 'Excess liability over auto + home', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-3020', policy: 'POL-2022-2200', type: 'Collision — rear-end', date: '2025-03-08', amount: 9500, status: 'In progress' },
|
||||
{ id: 'CL-2650', policy: 'POL-2023-6600', type: 'Surgery reimbursement', date: '2024-10-15', amount: 12400, status: 'Resolved' },
|
||||
{ id: 'CL-2102', policy: 'POL-2020-0055', type: 'Storm damage — roof', date: '2024-06-22', amount: 8200, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Failed' },
|
||||
{ date: '2025-03-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-02-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-01-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'ALERT: April payment failed — auto-debit declined', type: 'payment' },
|
||||
{ date: 'Yesterday', text: 'Collision claim CL-3020 — repair estimate received ($9,500)', type: 'claim' },
|
||||
{ date: 'Mar 20', text: 'Auto policies up for renewal May 1 — quote comparison started', type: 'renewal' },
|
||||
{ date: 'Mar 08', text: 'Collision claim filed — CL-3020 (BMW rear-end)', type: 'claim' },
|
||||
{ date: 'Feb 15', text: 'Annual portfolio review — recommended umbrella increase', type: 'note' },
|
||||
{ date: 'Jan 20', text: 'Health claim CL-2650 resolved — $12,400 reimbursed', type: 'claim' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 5 · Sofía Campos Rojas */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const sofia: MockCustomer = {
|
||||
id: 'cust-005',
|
||||
name: 'Sofía Campos Rojas',
|
||||
initials: 'SC',
|
||||
type: 'Individual',
|
||||
documentId: '4-0220-0561',
|
||||
email: 'sofia.campos@icloud.com',
|
||||
phone: '+506 7233-0098',
|
||||
birthDate: '1995-12-03',
|
||||
gender: 'Female',
|
||||
address: 'Guanacaste, Liberia, Centro',
|
||||
since: '2024-01-15',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Young professional'],
|
||||
paymentStatus: 'Grace period',
|
||||
policies: [
|
||||
{ id: 'POL-2024-9901', line: 'Auto', carrier: 'INS', product: '2024 Mazda CX-30 — GUA-0098', premium: 1380, status: 'Active', renewal: '2026-01-15', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Instagram campaign' },
|
||||
{ id: 'POL-2024-9902', line: 'Life', carrier: 'Mapfre', product: 'Term life — 10yr, $100K', premium: 360, status: 'Active', renewal: '2026-01-15', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Elena Rojas (mother)', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-3101', policy: 'POL-2024-9901', type: 'Fender bender', date: '2025-03-20', amount: 1800, status: 'Under review' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Pending' },
|
||||
{ date: '2025-03-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 30, policy: 'POL-2024-9902', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2025-02-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'Grace period — April auto premium not yet received', type: 'payment' },
|
||||
{ date: 'Mar 22', text: 'Fender bender claim CL-3101 filed', type: 'claim' },
|
||||
{ date: 'Mar 01', text: 'Monthly payment received ($175)', type: 'payment' },
|
||||
{ date: 'Jan 15', text: 'Policies renewed — Auto + Life', type: 'renewal' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 6 · Quick Lead — Diego Herrera */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const diego: MockCustomer = {
|
||||
id: 'cust-006',
|
||||
name: 'Diego Herrera',
|
||||
initials: 'DH',
|
||||
type: 'Individual',
|
||||
documentId: '—',
|
||||
email: 'diego.h@gmail.com',
|
||||
phone: '+506 6100-4422',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
since: '2026-03-28',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Quick lead', 'Referral'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Mar 28', text: 'Quick lead captured — referred by Roberto Jiménez', type: 'onboarding' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 7 · Quick Lead — Valeria Núñez */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const valeria: MockCustomer = {
|
||||
id: 'cust-007',
|
||||
name: 'Valeria Núñez',
|
||||
initials: 'VN',
|
||||
type: 'Individual',
|
||||
documentId: '—',
|
||||
email: '',
|
||||
phone: '+506 8899-1100',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
since: '2026-04-01',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Quick lead', 'Walk-in'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Apr 01', text: 'Walk-in lead — interested in auto insurance', type: 'onboarding' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 8 · Lead — Andrés Mora Villalobos */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const andres: MockCustomer = {
|
||||
id: 'cust-008',
|
||||
name: 'Andrés Mora Villalobos',
|
||||
initials: 'AM',
|
||||
type: 'Individual',
|
||||
documentId: '1-1450-0221',
|
||||
email: 'andres.mora@empresa.cr',
|
||||
phone: '+506 7744-5566',
|
||||
birthDate: '1990-05-18',
|
||||
gender: 'Male',
|
||||
address: 'San José, Moravia, San Vicente',
|
||||
since: '2026-03-15',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Lead', 'Corporate referral'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Mar 15', text: 'Lead created — needs auto + health quotes', type: 'onboarding' },
|
||||
{ date: 'Mar 20', text: 'Discovery call completed — family of 3', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 9 · Lead — Corporación Tecnológica del Valle */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const corpTech: MockCustomer = {
|
||||
id: 'cust-009',
|
||||
name: 'Corporación Tecnológica del Valle',
|
||||
initials: 'CT',
|
||||
type: 'Corporate',
|
||||
documentId: '3-101-789456',
|
||||
email: 'rrhh@corptech.cr',
|
||||
phone: '+506 2234-8800',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: 'Heredia, Heredia, Zona Franca',
|
||||
since: '2026-02-10',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Lead', 'Corporate', 'Group health prospect'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Feb 10', text: 'Corporate lead — 45 employees, group health RFP', type: 'onboarding' },
|
||||
{ date: 'Mar 05', text: 'Census received — quoting in progress', type: 'quote' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 10 · Cancelled — Fernando Arias Blanco */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const fernando: MockCustomer = {
|
||||
id: 'cust-010',
|
||||
name: 'Fernando Arias Blanco',
|
||||
initials: 'FA',
|
||||
type: 'Individual',
|
||||
documentId: '1-0892-0344',
|
||||
email: 'fernando.arias@hotmail.com',
|
||||
phone: '+506 8322-0017',
|
||||
birthDate: '1982-09-14',
|
||||
gender: 'Male',
|
||||
address: 'Alajuela, San Carlos, Ciudad Quesada',
|
||||
since: '2020-04-01',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Cancelled', 'Win-back opportunity'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [
|
||||
{ id: 'POL-2020-1100', line: 'Auto', carrier: 'INS', product: '2018 Toyota Hilux — ALJ-4400', premium: 1200, status: 'Cancelled', renewal: '2024-04-01', icon: 'i-heroicons-truck', details: 'Cancelled — non-payment', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2021-2200', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Ciudad Quesada', premium: 650, status: 'Lapsed', renewal: '2024-06-15', icon: 'i-heroicons-home-modern', details: 'Lapsed — did not renew', referralChannel: 'Direct walk-in' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-1200', policy: 'POL-2020-1100', type: 'Theft attempt', date: '2023-08-10', amount: 3200, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2024-01-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2024-02-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Overdue' },
|
||||
{ date: '2024-03-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Overdue' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Apr 01', text: 'Auto policy cancelled — 3 months unpaid', type: 'policy' },
|
||||
{ date: 'Jun 15', text: 'Home policy lapsed — did not renew', type: 'policy' },
|
||||
{ date: 'Aug 20', text: 'Win-back call attempted — no answer', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* Exports */
|
||||
/* ────────────────────────────────────────────── */
|
||||
|
||||
export const MOCK_CUSTOMERS: MockCustomer[] = [maria, roberto, carolina, luis, sofia, diego, valeria, andres, corpTech, fernando]
|
||||
|
||||
export const MOCK_CUSTOMERS_BY_ID: Record<string, MockCustomer> = Object.fromEntries(
|
||||
MOCK_CUSTOMERS.map((c) => [c.id, c])
|
||||
)
|
||||
|
||||
/** Helper to format currency */
|
||||
export function fmtMoney(n: number): string {
|
||||
return `$${n.toLocaleString()}`
|
||||
}
|
||||
660
app/data/mock-renewals.ts
Normal file
660
app/data/mock-renewals.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// RENEWALS — Data Layer
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// --- Carrier Status ---
|
||||
export type CarrierRenewalStatus =
|
||||
| 'pending'
|
||||
| 'terms_received'
|
||||
| 'remarketing'
|
||||
| 'bound'
|
||||
| 'declined'
|
||||
| 'lapsed'
|
||||
|
||||
// --- Broker Workflow Status ---
|
||||
export type BrokerRenewalStatus =
|
||||
| 'unreviewed'
|
||||
| 'under_review'
|
||||
| 'proposal_sent'
|
||||
| 'awaiting_client_response'
|
||||
| 'awaiting_payment'
|
||||
| 'closed_renewed'
|
||||
| 'closed_remarketed'
|
||||
| 'closed_cancelled'
|
||||
| 'not_renewing'
|
||||
|
||||
export type RenewalPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
export type RetentionRisk = 'high' | 'medium' | 'low'
|
||||
|
||||
export type CancellationReason =
|
||||
| 'price'
|
||||
| 'service'
|
||||
| 'competitor'
|
||||
| 'coverage_gap'
|
||||
| 'business_closed'
|
||||
| 'non_payment'
|
||||
| 'carrier_declined'
|
||||
| 'other'
|
||||
|
||||
export interface CancellationData {
|
||||
reason: CancellationReason
|
||||
reasonDetail: string | null
|
||||
competitor: string | null
|
||||
competitorPremium: number | null
|
||||
recoverable: boolean
|
||||
exitDate: string
|
||||
}
|
||||
|
||||
export interface RenewalTask {
|
||||
id: string
|
||||
title: string
|
||||
type: 'review' | 'send_proposal' | 'follow_up' | 'collect_payment' | 'escalation'
|
||||
status: 'open' | 'in_progress' | 'overdue' | 'done'
|
||||
assignee: string
|
||||
dueDate: string
|
||||
aiGenerated: boolean
|
||||
slaPercent: number
|
||||
}
|
||||
|
||||
export interface RenewalCommunication {
|
||||
id: string
|
||||
type: 'email' | 'call' | 'note' | 'system'
|
||||
direction: 'inbound' | 'outbound' | 'internal'
|
||||
from: string
|
||||
to: string | null
|
||||
subject: string | null
|
||||
body: string
|
||||
aiDigest: string | null
|
||||
templateUsed: string | null
|
||||
timestamp: string
|
||||
partyRole: string
|
||||
}
|
||||
|
||||
export interface RenewalDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: 'current_policy' | 'renewal_terms' | 'proposal_sent' |
|
||||
'client_confirmation' | 'payment_receipt' | 'loss_runs' |
|
||||
'correspondence' | 'cancellation'
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
required: boolean
|
||||
fulfilled: boolean
|
||||
}
|
||||
|
||||
export interface RenewalParty {
|
||||
id: string
|
||||
role: 'insured' | 'carrier_rep' | 'producer' | 'handler' | 'ai_agent'
|
||||
name: string
|
||||
company: string | null
|
||||
phone: string | null
|
||||
email: string | null
|
||||
hasUnread: boolean
|
||||
}
|
||||
|
||||
export interface RenewalQuote {
|
||||
id: string
|
||||
carrier: string
|
||||
premium: number
|
||||
currency: 'USD' | 'CRC'
|
||||
coverageAmount: number
|
||||
deductible: number
|
||||
receivedAt: string
|
||||
recommended: boolean
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface CoverageLine {
|
||||
name: string
|
||||
currentAmount: number | string
|
||||
renewalAmount: number | string | null
|
||||
delta: string | null
|
||||
flag: 'increase' | 'decrease' | 'same' | 'new' | 'removed' | null
|
||||
}
|
||||
|
||||
export interface PolicyComparison {
|
||||
currentPremium: number
|
||||
renewalPremium: number | null
|
||||
premiumDelta: number | null
|
||||
currentDeductible: number
|
||||
renewalDeductible: number | null
|
||||
deductibleDelta: number | null
|
||||
coverageLines: CoverageLine[]
|
||||
aiAnalysis: string | null
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
stage: BrokerRenewalStatus
|
||||
lob: string | 'all'
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface RenewalHistoryEntry {
|
||||
year: number
|
||||
carrier: string
|
||||
premium: number
|
||||
outcome: 'renewed' | 'remarketed' | 'cancelled' | 'new'
|
||||
}
|
||||
|
||||
// --- List item ---
|
||||
export interface Renewal {
|
||||
id: string
|
||||
policyId: string
|
||||
policyNumber: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerType: 'individual' | 'corporate'
|
||||
carrier: string
|
||||
lob: string
|
||||
currentPremium: number
|
||||
renewalPremium: number | null
|
||||
premiumDelta: number | null
|
||||
currency: 'USD' | 'CRC'
|
||||
expiryDate: string
|
||||
daysUntilExpiry: number
|
||||
carrierStatus: CarrierRenewalStatus
|
||||
brokerStatus: BrokerRenewalStatus
|
||||
priority: RenewalPriority
|
||||
retentionRisk: RetentionRisk
|
||||
lossRatio: number
|
||||
yearsAsClient: number
|
||||
openClaims: number
|
||||
assignedTo: string
|
||||
lastContactDate: string | null
|
||||
slaPercent: number
|
||||
outstandingBalance: number
|
||||
paymentStatus: 'current' | 'overdue' | 'grace_period'
|
||||
}
|
||||
|
||||
// --- Detail ---
|
||||
export interface RenewalDetail extends Renewal {
|
||||
claimIds: string[]
|
||||
parties: RenewalParty[]
|
||||
tasks: RenewalTask[]
|
||||
communications: RenewalCommunication[]
|
||||
documents: RenewalDocument[]
|
||||
comparison: PolicyComparison | null
|
||||
quotes: RenewalQuote[]
|
||||
renewalHistory: RenewalHistoryEntry[]
|
||||
cancellationData: CancellationData | null
|
||||
commissionRate: number
|
||||
commissionAmount: number
|
||||
aiRenewalBrief: string | null
|
||||
aiTalkTrack: string[] | null
|
||||
aiRetentionFactors: string[] | null
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Label Maps
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const carrierStatusLabels: Record<CarrierRenewalStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
terms_received: 'Términos Recibidos',
|
||||
remarketing: 'En Remarketing',
|
||||
bound: 'Vinculada',
|
||||
declined: 'Declinada',
|
||||
lapsed: 'Vencida',
|
||||
}
|
||||
|
||||
export const brokerStatusLabels: Record<BrokerRenewalStatus, string> = {
|
||||
unreviewed: 'Sin Revisar',
|
||||
under_review: 'En Revisión',
|
||||
proposal_sent: 'Propuesta Enviada',
|
||||
awaiting_client_response: 'Esperando Cliente',
|
||||
awaiting_payment: 'Esperando Pago',
|
||||
closed_renewed: 'Renovada',
|
||||
closed_remarketed: 'Remarketing Exitoso',
|
||||
closed_cancelled: 'Cancelada',
|
||||
not_renewing: 'No Renueva',
|
||||
}
|
||||
|
||||
export const priorityLabels: Record<RenewalPriority, string> = {
|
||||
critical: 'Crítica',
|
||||
high: 'Alta',
|
||||
medium: 'Media',
|
||||
low: 'Baja',
|
||||
}
|
||||
|
||||
export const retentionRiskLabels: Record<RetentionRisk, string> = {
|
||||
high: 'Alto',
|
||||
medium: 'Medio',
|
||||
low: 'Bajo',
|
||||
}
|
||||
|
||||
export const cancellationReasonLabels: Record<CancellationReason, string> = {
|
||||
price: 'Precio',
|
||||
service: 'Servicio',
|
||||
competitor: 'Competencia',
|
||||
coverage_gap: 'Cobertura insuficiente',
|
||||
business_closed: 'Cierre de negocio',
|
||||
non_payment: 'Falta de pago',
|
||||
carrier_declined: 'Aseguradora declinó',
|
||||
other: 'Otro',
|
||||
}
|
||||
|
||||
export const expiryBuckets = {
|
||||
expired: { label: 'Vencidas', min: -Infinity, max: -1 },
|
||||
this_week: { label: 'Esta semana', min: 0, max: 7 },
|
||||
thirty_days: { label: '30 días', min: 8, max: 30 },
|
||||
sixty_days: { label: '60 días', min: 31, max: 60 },
|
||||
ninety_days: { label: '90 días', min: 61, max: 90 },
|
||||
future: { label: '90+ días', min: 91, max: Infinity },
|
||||
}
|
||||
|
||||
export const templateMergeFields = [
|
||||
'{{customer_name}}', '{{policy_number}}', '{{lob}}', '{{carrier}}',
|
||||
'{{current_premium}}', '{{renewal_premium}}', '{{premium_delta}}',
|
||||
'{{expiry_date}}', '{{coverage_amount}}', '{{deductible}}',
|
||||
'{{producer_name}}', '{{producer_phone}}', '{{producer_email}}',
|
||||
'{{company_name}}', '{{loss_ratio}}', '{{years_as_client}}',
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function expiryBucket(days: number): string {
|
||||
for (const [key, b] of Object.entries(expiryBuckets)) {
|
||||
if (days >= b.min && days <= b.max) return key
|
||||
}
|
||||
return 'future'
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Email Templates
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const MOCK_EMAIL_TEMPLATES: EmailTemplate[] = [
|
||||
{
|
||||
id: 'tpl-1', name: 'Aviso de Renovación', stage: 'under_review', lob: 'all', isDefault: true,
|
||||
subject: 'Su póliza {{policy_number}} próxima a vencer',
|
||||
body: 'Estimado/a {{customer_name}},\n\nLe informamos que su póliza {{policy_number}} de {{lob}} con {{carrier}} vence el {{expiry_date}}.\n\nPrima actual: {{current_premium}}\n\nNos pondremos en contacto próximamente con los términos de renovación.\n\nSaludos cordiales,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2', name: 'Propuesta de Renovación', stage: 'proposal_sent', lob: 'all', isDefault: true,
|
||||
subject: 'Términos de renovación — {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nAdjunto los términos de renovación para su póliza {{policy_number}}:\n\n• Prima actual: {{current_premium}}\n• Prima renovación: {{renewal_premium}} ({{premium_delta}})\n• Cobertura: {{coverage_amount}}\n• Deducible: {{deductible}}\n\nPor favor confirme si desea proceder con la renovación.\n\nQuedamos atentos,\n{{producer_name}}\n{{producer_phone}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3', name: 'Seguimiento #1', stage: 'awaiting_client_response', lob: 'all', isDefault: true,
|
||||
subject: 'Recordatorio: renovación de póliza {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nLe recordamos que estamos a la espera de su confirmación para renovar la póliza {{policy_number}} que vence el {{expiry_date}}.\n\nPor favor responda a este correo o comuníquese al {{producer_phone}} para evitar cualquier lapso en su cobertura.\n\nSaludos,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-4', name: 'Seguimiento #2', stage: 'awaiting_client_response', lob: 'all', isDefault: true,
|
||||
subject: 'Segundo aviso: su póliza {{policy_number}} vence el {{expiry_date}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nEste es un segundo aviso sobre la renovación de su póliza {{policy_number}}. La fecha de vencimiento es {{expiry_date}} y necesitamos su confirmación a la brevedad posible.\n\nSin su confirmación, la póliza podría vencer sin renovación, dejándole sin cobertura.\n\nPor favor contáctenos de inmediato.\n\n{{producer_name}}\n{{producer_email}} | {{producer_phone}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-5', name: 'Confirmación de Pago', stage: 'awaiting_payment', lob: 'all', isDefault: true,
|
||||
subject: 'Pago pendiente — renovación {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nGracias por confirmar la renovación de su póliza {{policy_number}}.\n\nPara completar el proceso, le solicitamos realizar el pago de {{renewal_premium}} a la brevedad.\n\nUna vez recibido el pago, procederemos con la emisión de su nueva póliza.\n\nSaludos,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-6', name: 'Renovación Completada', stage: 'closed_renewed', lob: 'all', isDefault: true,
|
||||
subject: 'Confirmación de renovación — {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nNos complace confirmar que su póliza {{policy_number}} ha sido renovada exitosamente con {{carrier}}.\n\nNueva vigencia: {{expiry_date}}\nPrima: {{renewal_premium}}\n\nAdjunto encontrará su nueva póliza. No dude en contactarnos para cualquier consulta.\n\nGracias por su confianza,\n{{producer_name}}\n{{company_name}}',
|
||||
},
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Renewals (Pipeline List)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const MOCK_RENEWALS: Renewal[] = [
|
||||
{
|
||||
id: 'REN-001', policyId: 'POL-0955', policyNumber: 'PROP-2024-HP-001',
|
||||
customerId: 'CUS-001', customerName: 'Hotel Pacífico Resort', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'Auto', currentPremium: 18500, renewalPremium: 22570,
|
||||
premiumDelta: 22, currency: 'USD', expiryDate: '2026-04-11', daysUntilExpiry: 3,
|
||||
carrierStatus: 'terms_received', brokerStatus: 'awaiting_client_response',
|
||||
priority: 'critical', retentionRisk: 'high', lossRatio: 0.42, yearsAsClient: 5,
|
||||
openClaims: 1, assignedTo: 'Marco V.', lastContactDate: '2026-04-03',
|
||||
slaPercent: 95, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-002', policyId: 'POL-1034', policyNumber: 'GR-2024-CM-001',
|
||||
customerId: 'CUS-002', customerName: 'Constructora Montes', customerType: 'corporate',
|
||||
carrier: 'Mapfre', lob: 'General Risk', currentPremium: 32000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-04-22', daysUntilExpiry: 14,
|
||||
carrierStatus: 'pending', brokerStatus: 'under_review',
|
||||
priority: 'high', retentionRisk: 'medium', lossRatio: 0.15, yearsAsClient: 3,
|
||||
openClaims: 0, assignedTo: 'Ana R.', lastContactDate: '2026-04-01',
|
||||
slaPercent: 55, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-003', policyId: 'POL-1077', policyNumber: 'LIFE-2024-CR-001',
|
||||
customerId: 'CUS-003', customerName: 'Carmen Ruiz', customerType: 'individual',
|
||||
carrier: 'Pan-American Life', lob: 'Life', currentPremium: 2800, renewalPremium: 2940,
|
||||
premiumDelta: 5, currency: 'USD', expiryDate: '2026-04-15', daysUntilExpiry: 7,
|
||||
carrierStatus: 'terms_received', brokerStatus: 'awaiting_payment',
|
||||
priority: 'medium', retentionRisk: 'low', lossRatio: 0, yearsAsClient: 8,
|
||||
openClaims: 0, assignedTo: 'Ana R.', lastContactDate: '2026-04-06',
|
||||
slaPercent: 30, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-004', policyId: 'POL-1110', policyNumber: 'AUTO-2024-ABC-001',
|
||||
customerId: 'CUS-004', customerName: 'Empresa ABC S.A.', customerType: 'corporate',
|
||||
carrier: 'Qualitas', lob: 'Auto', currentPremium: 22400, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-05-08', daysUntilExpiry: 30,
|
||||
carrierStatus: 'pending', brokerStatus: 'unreviewed',
|
||||
priority: 'low', retentionRisk: 'low', lossRatio: 0.08, yearsAsClient: 2,
|
||||
openClaims: 0, assignedTo: 'Unassigned', lastContactDate: null,
|
||||
slaPercent: 10, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-005', policyId: 'POL-0888', policyNumber: 'HEALTH-2024-CSJ-001',
|
||||
customerId: 'CUS-005', customerName: 'Clínica San José', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'Health', currentPremium: 45000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-04-01', daysUntilExpiry: -7,
|
||||
carrierStatus: 'lapsed', brokerStatus: 'closed_cancelled',
|
||||
priority: 'high', retentionRisk: 'high', lossRatio: 0.65, yearsAsClient: 4,
|
||||
openClaims: 0, assignedTo: 'Marco V.', lastContactDate: '2026-03-28',
|
||||
slaPercent: 100, outstandingBalance: 8500, paymentStatus: 'overdue',
|
||||
},
|
||||
{
|
||||
id: 'REN-006', policyId: 'POL-1200', policyNumber: 'GR-2024-TN-001',
|
||||
customerId: 'CUS-006', customerName: 'Transportes del Norte', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'General Risk', currentPremium: 38000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-06-07', daysUntilExpiry: 60,
|
||||
carrierStatus: 'pending', brokerStatus: 'unreviewed',
|
||||
priority: 'high', retentionRisk: 'high', lossRatio: 0.55, yearsAsClient: 6,
|
||||
openClaims: 1, assignedTo: 'Unassigned', lastContactDate: null,
|
||||
slaPercent: 15, outstandingBalance: 21000, paymentStatus: 'overdue',
|
||||
},
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Renewal Details
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ren001Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[0],
|
||||
claimIds: ['CLM-0048'],
|
||||
parties: [
|
||||
{ id: 'rp-1', role: 'insured', name: 'Carlos Montero', company: 'Hotel Pacífico Resort', phone: '+507 6700-1234', email: 'cmontero@hotelpacífico.com', hasUnread: true },
|
||||
{ id: 'rp-2', role: 'carrier_rep', name: 'Lucía Vargas', company: 'ASSA', phone: '+507 6600-9876', email: 'lvargas@assa.com', hasUnread: false },
|
||||
{ id: 'rp-3', role: 'handler', name: 'Marco V.', company: null, phone: null, email: 'marco@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-1', title: 'Follow up with client — no response in 5 days', type: 'follow_up', status: 'overdue', assignee: 'Marco V.', dueDate: '2026-04-06', aiGenerated: true, slaPercent: 110 },
|
||||
{ id: 'rt-2', title: 'Prepare counter-proposal (premium increase justification)', type: 'send_proposal', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-09', aiGenerated: false, slaPercent: 80 },
|
||||
{ id: 'rt-3', title: 'Review loss runs before presenting to client', type: 'review', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-02', aiGenerated: false, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-1', type: 'email', direction: 'outbound', from: 'Marco V.', to: 'Carlos Montero', subject: 'Términos de renovación — PROP-2024-HP-001', body: 'Estimado Carlos, adjunto los términos de renovación de su póliza de auto. La prima pasa de $18,500 a $22,570 (+22%) debido al reclamo abierto y al ajuste de mercado. Quedo atento a su respuesta.', aiDigest: null, templateUsed: 'tpl-2', timestamp: '2026-04-03T10:30:00', partyRole: 'insured' },
|
||||
{ id: 'rc-2', type: 'email', direction: 'inbound', from: 'Lucía Vargas', to: 'Marco V.', subject: 'RE: Renewal terms HP-001', body: 'Marco, adjunto los términos definitivos para Hotel Pacífico. El aumento refleja el siniestro abierto CLM-0048 y el ajuste de tarifa general para flota comercial.', aiDigest: 'ASSA confirma aumento de 22% justificado por siniestro abierto y ajuste de tarifa.', templateUsed: null, timestamp: '2026-04-02T15:45:00', partyRole: 'carrier_rep' },
|
||||
{ id: 'rc-3', type: 'note', direction: 'internal', from: 'Marco V.', to: null, subject: null, body: 'Client not responding to emails. Called twice, went to voicemail. Need to escalate if no response by Apr 8.', aiDigest: null, templateUsed: null, timestamp: '2026-04-06T09:00:00', partyRole: 'handler' },
|
||||
{ id: 'rc-4', type: 'system', direction: 'internal', from: 'System', to: null, subject: null, body: 'AI escalation: Client non-response exceeds 5 days. Recommended action: send Seguimiento #1 template.', aiDigest: null, templateUsed: null, timestamp: '2026-04-08T08:00:00', partyRole: 'ai_agent' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-1', name: 'Póliza actual — PROP-2024-HP-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-2', name: 'Términos renovación ASSA 2026.pdf', category: 'renewal_terms', uploadedBy: 'Lucía Vargas', uploadedAt: '2026-04-02', required: true, fulfilled: true },
|
||||
{ id: 'rd-3', name: 'Propuesta enviada a cliente.pdf', category: 'proposal_sent', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', required: true, fulfilled: true },
|
||||
{ id: 'rd-4', name: 'Loss runs 2023-2025.pdf', category: 'loss_runs', uploadedBy: 'ASSA', uploadedAt: '2026-04-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-5', name: 'Client confirmation', category: 'client_confirmation', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
{ id: 'rd-6', name: 'Payment receipt', category: 'payment_receipt', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: {
|
||||
currentPremium: 18500, renewalPremium: 22570, premiumDelta: 22,
|
||||
currentDeductible: 500, renewalDeductible: 750, deductibleDelta: 50,
|
||||
coverageLines: [
|
||||
{ name: 'Responsabilidad Civil', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
{ name: 'Daños Propios', currentAmount: 45000, renewalAmount: 45000, delta: '=', flag: 'same' },
|
||||
{ name: 'Robo Total', currentAmount: 45000, renewalAmount: 40000, delta: '-11%', flag: 'decrease' },
|
||||
{ name: 'Asistencia Vial', currentAmount: 'Incluida', renewalAmount: 'Incluida', delta: '=', flag: 'same' },
|
||||
{ name: 'Vidrios', currentAmount: 2000, renewalAmount: 2500, delta: '+25%', flag: 'increase' },
|
||||
],
|
||||
aiAnalysis: 'El aumento de 22% se justifica por el siniestro abierto CLM-0048 ($128K reservado) y el ajuste de tarifa general de ASSA para flotas comerciales (+8% promedio). El deducible subió 50%. La cobertura de Robo Total baja 11% — recomendar al cliente mantener nivel actual. Se detecta oportunidad de negociar Vidrios si se acepta deducible mayor.',
|
||||
},
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 18500, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 16200, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'Qualitas', premium: 15800, outcome: 'remarketed' },
|
||||
{ year: 2022, carrier: 'Qualitas', premium: 14500, outcome: 'renewed' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 15, commissionAmount: 3385.50,
|
||||
aiRenewalBrief: 'Hotel Pacífico es un cliente corporativo de 5 años con prima actual de $18,500 en Auto. ASSA ofrece renovación a $22,570 (+22%), justificado por siniestro abierto CLM-0048 con $128K reservados y ajuste de tarifa de flota. El cliente no ha respondido en 5 días. El deducible sube de $500 a $750. Loss ratio de 42% está por encima del promedio para esta línea. Comisión proyectada: $3,385. Riesgo de retención ALTO.',
|
||||
aiTalkTrack: [
|
||||
'Abrir con empatía: reconocer que un aumento de 22% es significativo y que entendemos su preocupación.',
|
||||
'Explicar el contexto: el siniestro abierto impacta directamente la tarificación. ASSA subió tarifas de flota 8% en general.',
|
||||
'Destacar valor: 5 años como cliente, historial mayormente limpio. Hemos negociado mantener la cobertura de RC en $100K sin cambio.',
|
||||
'Proponer alternativa: si acepta deducible de $1,000 en vez de $750, podemos negociar bajar prima a ~$21,500.',
|
||||
'Urgencia: la póliza vence el 11 de abril. Sin confirmación antes del 9, hay riesgo de lapso.',
|
||||
],
|
||||
aiRetentionFactors: [
|
||||
'5 años como cliente — relación establecida',
|
||||
'Siniestro abierto CLM-0048 dificulta remarketing',
|
||||
'Flota comercial con cobertura especializada — pocas opciones en mercado local',
|
||||
'Balance al día, historial de pago puntual',
|
||||
'Riesgo: aumento de 22% puede motivar búsqueda de alternativas',
|
||||
],
|
||||
}
|
||||
|
||||
const ren005Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[4],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-10', role: 'insured', name: 'Dr. Alejandro Solís', company: 'Clínica San José', phone: '+507 6500-3456', email: 'asolis@clinicasanjose.com', hasUnread: false },
|
||||
{ id: 'rp-11', role: 'handler', name: 'Marco V.', company: null, phone: null, email: 'marco@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-10', title: 'Document cancellation reason', type: 'review', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-02', aiGenerated: false, slaPercent: 100 },
|
||||
{ id: 'rt-11', title: 'Win-back outreach — 30 day follow-up', type: 'follow_up', status: 'open', assignee: 'Marco V.', dueDate: '2026-05-01', aiGenerated: true, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-10', type: 'email', direction: 'inbound', from: 'Dr. Alejandro Solís', to: 'Marco V.', subject: 'Decisión sobre renovación', body: 'Marco, lamentablemente hemos decidido no renovar con ASSA. Recibimos una oferta más competitiva de Mapfre a $38,500 con mejores términos de copago. Agradecemos el servicio durante estos 4 años.', aiDigest: 'Cliente cancela por oferta competidora de Mapfre a $38,500 (vs $45,000 actual). Menciona mejores copagos.', templateUsed: null, timestamp: '2026-03-28T11:00:00', partyRole: 'insured' },
|
||||
{ id: 'rc-11', type: 'note', direction: 'internal', from: 'Marco V.', to: null, subject: null, body: 'Lost to Mapfre. Client says their copay terms are better. ASSA was unable to match. Client open to reconsidering in 12 months if terms change. Marked as recoverable.', aiDigest: null, templateUsed: null, timestamp: '2026-03-29T09:00:00', partyRole: 'handler' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-10', name: 'Póliza actual — HEALTH-2024-CSJ-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-02-15', required: true, fulfilled: true },
|
||||
{ id: 'rd-11', name: 'Cancellation email from client.pdf', category: 'cancellation', uploadedBy: 'Marco V.', uploadedAt: '2026-03-28', required: false, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 45000, outcome: 'cancelled' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 42000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 38000, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'ASSA', premium: 35000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: {
|
||||
reason: 'competitor',
|
||||
reasonDetail: 'Mapfre offered better copay terms and lower premium',
|
||||
competitor: 'Mapfre',
|
||||
competitorPremium: 38500,
|
||||
recoverable: true,
|
||||
exitDate: '2026-04-01',
|
||||
},
|
||||
commissionRate: 12, commissionAmount: 0,
|
||||
aiRenewalBrief: 'Clínica San José — cliente corporativo de 4 años, póliza de Salud Grupal con ASSA por $45,000. Cancelada por competencia: Mapfre ofreció $38,500 con mejores copagos. Loss ratio 65% (alto). El cliente expresó apertura a reconsiderar en 12 meses. Marcado como recuperable para win-back.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: [
|
||||
'Loss ratio de 65% — difícil competir en precio',
|
||||
'Mapfre ofreció $6,500 menos con mejores copagos',
|
||||
'Cliente indica apertura a reconsiderar en 12 meses',
|
||||
'Balance pendiente de $8,500 complica relación',
|
||||
],
|
||||
}
|
||||
|
||||
const ren002Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[1],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-20', role: 'insured', name: 'Ing. Ricardo Montes', company: 'Constructora Montes', phone: '+507 6800-2222', email: 'rmontes@constructoramontes.com', hasUnread: false },
|
||||
{ id: 'rp-21', role: 'carrier_rep', name: 'Fernando Gil', company: 'Mapfre', phone: '+507 6600-3333', email: 'fgil@mapfre.com', hasUnread: false },
|
||||
{ id: 'rp-22', role: 'handler', name: 'Ana R.', company: null, phone: null, email: 'ana@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-20', title: 'Request renewal terms from Mapfre', type: 'review', status: 'in_progress', assignee: 'Ana R.', dueDate: '2026-04-10', aiGenerated: false, slaPercent: 55 },
|
||||
{ id: 'rt-21', title: 'Review last year loss runs', type: 'review', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', aiGenerated: false, slaPercent: 40 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-20', type: 'email', direction: 'outbound', from: 'Ana R.', to: 'Fernando Gil', subject: 'Solicitud de términos — GR-2024-CM-001', body: 'Fernando, buen día. Solicitamos los términos de renovación para la póliza GR-2024-CM-001 de Constructora Montes, vigente hasta el 22 de abril. Agradecemos envío a la brevedad.', aiDigest: null, templateUsed: null, timestamp: '2026-04-01T14:00:00', partyRole: 'carrier_rep' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-20', name: 'Póliza actual — GR-2024-CM-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-15', required: true, fulfilled: true },
|
||||
{ id: 'rd-21', name: 'Loss runs 2024-2025.pdf', category: 'loss_runs', uploadedBy: 'Mapfre', uploadedAt: '2026-03-20', required: true, fulfilled: true },
|
||||
{ id: 'rd-22', name: 'Renewal terms', category: 'renewal_terms', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Mapfre', premium: 32000, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Mapfre', premium: 29000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 27500, outcome: 'remarketed' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 12, commissionAmount: 3840,
|
||||
aiRenewalBrief: 'Constructora Montes — cliente corporativo de 3 años, General Risk con Mapfre por $32,000. Términos de renovación aún no recibidos. Loss ratio bajo (15%). Sin reclamos abiertos. Buena relación con carrier. Se espera renovación sin complicaciones pero se necesitan los términos antes del 15 de abril para cumplir SLA.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: [
|
||||
'Loss ratio de 15% — excelente para la línea',
|
||||
'Sin reclamos abiertos',
|
||||
'3 años como cliente, relación estable',
|
||||
'Riesgo moderado: constructora puede buscar cotizaciones competitivas',
|
||||
],
|
||||
}
|
||||
|
||||
const ren003Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[2],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-30', role: 'insured', name: 'Carmen Ruiz', company: null, phone: '+507 6400-5555', email: 'carmen.ruiz@gmail.com', hasUnread: false },
|
||||
{ id: 'rp-31', role: 'handler', name: 'Ana R.', company: null, phone: null, email: 'ana@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-30', title: 'Collect payment — client confirmed renewal', type: 'collect_payment', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', aiGenerated: false, slaPercent: 30 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-30', type: 'email', direction: 'outbound', from: 'Ana R.', to: 'Carmen Ruiz', subject: 'Confirmación de pago — LIFE-2024-CR-001', body: 'Estimada Carmen, gracias por confirmar la renovación de su póliza de vida. La prima para el nuevo período es de $2,940. Por favor realice la transferencia para completar el proceso.', aiDigest: null, templateUsed: 'tpl-5', timestamp: '2026-04-06T10:00:00', partyRole: 'insured' },
|
||||
{ id: 'rc-31', type: 'email', direction: 'inbound', from: 'Carmen Ruiz', to: 'Ana R.', subject: 'RE: Renovación vida', body: 'Ana, confirmo que deseo renovar. Haré la transferencia esta semana.', aiDigest: 'Cliente confirma renovación, promete pago esta semana.', templateUsed: null, timestamp: '2026-04-05T16:30:00', partyRole: 'insured' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-30', name: 'Póliza actual — LIFE-2024-CR-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-31', name: 'Términos Pan-American Life 2026.pdf', category: 'renewal_terms', uploadedBy: 'Pan-American Life', uploadedAt: '2026-03-25', required: true, fulfilled: true },
|
||||
{ id: 'rd-32', name: 'Propuesta enviada.pdf', category: 'proposal_sent', uploadedBy: 'Ana R.', uploadedAt: '2026-03-28', required: true, fulfilled: true },
|
||||
{ id: 'rd-33', name: 'Client confirmation email.pdf', category: 'client_confirmation', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', required: true, fulfilled: true },
|
||||
{ id: 'rd-34', name: 'Payment receipt', category: 'payment_receipt', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: {
|
||||
currentPremium: 2800, renewalPremium: 2940, premiumDelta: 5,
|
||||
currentDeductible: 0, renewalDeductible: 0, deductibleDelta: 0,
|
||||
coverageLines: [
|
||||
{ name: 'Vida Individual', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
{ name: 'Muerte Accidental', currentAmount: 50000, renewalAmount: 50000, delta: '=', flag: 'same' },
|
||||
{ name: 'Incapacidad Total', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
],
|
||||
aiAnalysis: 'Aumento de 5% es estándar para ajuste de edad en seguro de vida. Coberturas sin cambio. Renovación rutinaria sin complicaciones.',
|
||||
},
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Pan-American Life', premium: 2800, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Pan-American Life', premium: 2650, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'Pan-American Life', premium: 2500, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'Pan-American Life', premium: 2350, outcome: 'renewed' },
|
||||
{ year: 2021, carrier: 'Pan-American Life', premium: 2200, outcome: 'renewed' },
|
||||
{ year: 2020, carrier: 'Pan-American Life', premium: 2050, outcome: 'renewed' },
|
||||
{ year: 2019, carrier: 'Pan-American Life', premium: 1900, outcome: 'renewed' },
|
||||
{ year: 2018, carrier: 'Pan-American Life', premium: 1750, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 20, commissionAmount: 588,
|
||||
aiRenewalBrief: 'Carmen Ruiz — cliente individual de 8 años, póliza de Vida con Pan-American Life. Aumento de 5% por ajuste de edad, estándar. Cliente confirmó renovación, pendiente cobro de $2,940. Excelente historial de retención. Sin reclamos.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: ['8 años como cliente — altamente leal', 'Sin reclamos, loss ratio 0%', 'Aumento de 5% es mínimo y esperado'],
|
||||
}
|
||||
|
||||
const ren004Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[3],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-40', role: 'insured', name: 'Lic. Patricia Vega', company: 'Empresa ABC S.A.', phone: '+507 6300-7777', email: 'pvega@empresaabc.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-40', title: 'Assign handler and begin review', type: 'review', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-15', aiGenerated: true, slaPercent: 10 },
|
||||
],
|
||||
communications: [],
|
||||
documents: [
|
||||
{ id: 'rd-40', name: 'Póliza actual — AUTO-2024-ABC-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-04-01', required: true, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Qualitas', premium: 22400, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Qualitas', premium: 21000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 15, commissionAmount: 3360,
|
||||
aiRenewalBrief: 'Empresa ABC S.A. — flota Auto con Qualitas por $22,400. Renovación sin asignar, 30 días hasta vencimiento. Sin reclamos, loss ratio 8%. Necesita asignación inmediata para cumplir SLA.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: ['Loss ratio bajo (8%)', '2 años como cliente', 'Sin complicaciones previas'],
|
||||
}
|
||||
|
||||
const ren006Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[5],
|
||||
claimIds: ['CLM-0043'],
|
||||
parties: [
|
||||
{ id: 'rp-60', role: 'insured', name: 'Gerardo Núñez', company: 'Transportes del Norte', phone: '+507 6200-8888', email: 'gnunez@transportesnorte.com', hasUnread: true },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-60', title: 'Assign handler — high-risk renewal', type: 'review', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-20', aiGenerated: true, slaPercent: 15 },
|
||||
{ id: 'rt-61', title: 'Address $21K outstanding balance before renewal', type: 'escalation', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-18', aiGenerated: true, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-60', type: 'system', direction: 'internal', from: 'System', to: null, subject: null, body: 'AI alert: Transportes del Norte has $21,000 outstanding balance and an open claim (CLM-0043). This renewal requires early attention due to high retention risk.', aiDigest: null, templateUsed: null, timestamp: '2026-04-08T08:00:00', partyRole: 'ai_agent' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-60', name: 'Póliza actual — GR-2024-TN-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-15', required: true, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 38000, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 35000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 32000, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'ASSA', premium: 28000, outcome: 'renewed' },
|
||||
{ year: 2021, carrier: 'ASSA', premium: 25000, outcome: 'renewed' },
|
||||
{ year: 2020, carrier: 'ASSA', premium: 22000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 12, commissionAmount: 4560,
|
||||
aiRenewalBrief: 'Transportes del Norte — cliente corporativo de 6 años, General Risk con ASSA por $38,000. ALERTA: balance pendiente de $21,000 y reclamo abierto CLM-0043 ($45K reservado). Loss ratio de 55% dificulta la negociación. Necesita atención temprana a pesar de tener 60 días hasta vencimiento. Comisión en riesgo: $4,560.',
|
||||
aiTalkTrack: [
|
||||
'Primero resolver el tema del balance pendiente de $21K antes de hablar de renovación.',
|
||||
'Reconocer que es un cliente de 6 años con historial generalmente positivo.',
|
||||
'El reclamo abierto CLM-0043 impactará los términos — preparar al cliente para posible aumento.',
|
||||
'Proponer plan de pago para el balance pendiente como condición para negociar mejores términos de renovación.',
|
||||
'Si ASSA sube prima significativamente, tener plan B de remarketing con Mapfre.',
|
||||
],
|
||||
aiRetentionFactors: [
|
||||
'6 años como cliente — relación de largo plazo',
|
||||
'Balance pendiente de $21K — señal de alerta',
|
||||
'Reclamo abierto CLM-0043 por $45K reservado',
|
||||
'Loss ratio 55% — por encima del promedio',
|
||||
'Comisión de $4,560 en riesgo',
|
||||
'Flota de transporte — pocas opciones en mercado local',
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_RENEWAL_DETAILS: Record<string, RenewalDetail> = {
|
||||
'REN-001': ren001Detail,
|
||||
'REN-002': ren002Detail,
|
||||
'REN-003': ren003Detail,
|
||||
'REN-004': ren004Detail,
|
||||
'REN-005': ren005Detail,
|
||||
'REN-006': ren006Detail,
|
||||
}
|
||||
699
app/data/mock-support.ts
Normal file
699
app/data/mock-support.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
// ─── Support Ticket System — Types, Labels, Helpers, Mock Data ───────────────
|
||||
|
||||
// ── Channel ──
|
||||
export type SupportChannel = 'whatsapp' | 'email' | 'phone' | 'walk_in' | 'web_form'
|
||||
|
||||
// ── Routing Tier ──
|
||||
export type RoutingTier = 'tier1_auto' | 'tier2_rule' | 'tier3_open'
|
||||
|
||||
// ── Ticket Status ──
|
||||
export type TicketStatus = 'open' | 'in_progress' | 'pending_customer' | 'resolved'
|
||||
|
||||
// ── Priority ──
|
||||
export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||
|
||||
// ── Intent Category (LLM classification) ──
|
||||
export type IntentCategory =
|
||||
| 'payment_inquiry'
|
||||
| 'claim_report'
|
||||
| 'sales_interest'
|
||||
| 'doc_request'
|
||||
| 'policy_question'
|
||||
| 'complaint'
|
||||
| 'endorsement'
|
||||
| 'certificate_request'
|
||||
| 'general'
|
||||
|
||||
// ── Routed Queue ──
|
||||
export type RoutedQueue = 'collections' | 'claims' | 'sales' | 'renewals' | 'operations' | 'open_pool'
|
||||
|
||||
// ── Message Types ──
|
||||
export type MessageType = 'whatsapp' | 'email' | 'phone_note' | 'internal_note' | 'system'
|
||||
export type MessageDirection = 'inbound' | 'outbound' | 'internal'
|
||||
|
||||
// ── Routing Rule Type ──
|
||||
export type RoutingRuleType = 'customer_match' | 'lob_specialist' | 'keyword_intent' | 'doc_auto_fulfill'
|
||||
|
||||
// ─── Interfaces ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TicketMessage {
|
||||
id: string
|
||||
type: MessageType
|
||||
direction: MessageDirection
|
||||
from: string
|
||||
to: string | null
|
||||
subject: string | null
|
||||
body: string
|
||||
timestamp: string
|
||||
aiDigest: string | null
|
||||
deliveryStatus?: 'sent' | 'delivered' | 'read'
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string
|
||||
subject: string
|
||||
channel: SupportChannel
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
intentCategory: IntentCategory
|
||||
routingTier: RoutingTier
|
||||
routedQueue: RoutedQueue
|
||||
customerId: string | null
|
||||
customerName: string
|
||||
policyId: string | null
|
||||
policyNumber: string | null
|
||||
assignedTo: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
daysOpen: number
|
||||
slaPercent: number
|
||||
messageCount: number
|
||||
lastMessagePreview: string
|
||||
}
|
||||
|
||||
export interface SupportTicketDetail extends SupportTicket {
|
||||
customerEmail: string | null
|
||||
customerPhone: string | null
|
||||
linkedPolicies: { id: string; number: string; carrier: string; lob: string; status: string; renewal: string }[]
|
||||
linkedClaims: { id: string; type: string; status: string }[]
|
||||
messages: TicketMessage[]
|
||||
aiSummary: string
|
||||
aiSuggestedIntent: IntentCategory
|
||||
aiConfidence: number
|
||||
routingTrace: { step: string; result: string; timestamp: string }[]
|
||||
}
|
||||
|
||||
export interface RoutingRule {
|
||||
id: string
|
||||
tier: RoutingTier
|
||||
type: RoutingRuleType
|
||||
name: string
|
||||
condition: string
|
||||
targetQueue: RoutedQueue
|
||||
targetAgent: string | null
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
// ─── Label Maps ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CHANNEL_LABELS: Record<SupportChannel, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
email: 'Email',
|
||||
phone: 'Teléfono',
|
||||
walk_in: 'Presencial',
|
||||
web_form: 'Formulario Web',
|
||||
}
|
||||
|
||||
export const CHANNEL_ICONS: Record<SupportChannel, string> = {
|
||||
whatsapp: 'i-heroicons-chat-bubble-left-ellipsis',
|
||||
email: 'i-heroicons-envelope',
|
||||
phone: 'i-heroicons-phone',
|
||||
walk_in: 'i-heroicons-building-storefront',
|
||||
web_form: 'i-heroicons-globe-alt',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<TicketStatus, string> = {
|
||||
open: 'Abierto',
|
||||
in_progress: 'En Proceso',
|
||||
pending_customer: 'Esperando Cliente',
|
||||
resolved: 'Resuelto',
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<TicketPriority, string> = {
|
||||
urgent: 'Urgente',
|
||||
high: 'Alta',
|
||||
medium: 'Media',
|
||||
low: 'Baja',
|
||||
}
|
||||
|
||||
export const INTENT_LABELS: Record<IntentCategory, string> = {
|
||||
payment_inquiry: 'Consulta de Pago',
|
||||
claim_report: 'Reporte de Siniestro',
|
||||
sales_interest: 'Interés en Seguros',
|
||||
doc_request: 'Solicitud de Documento',
|
||||
policy_question: 'Consulta de Póliza',
|
||||
complaint: 'Queja',
|
||||
endorsement: 'Endoso',
|
||||
certificate_request: 'Certificado',
|
||||
general: 'General',
|
||||
}
|
||||
|
||||
export const TIER_LABELS: Record<RoutingTier, string> = {
|
||||
tier1_auto: 'Tier 1 — Auto',
|
||||
tier2_rule: 'Tier 2 — Regla',
|
||||
tier3_open: 'Tier 3 — Pool',
|
||||
}
|
||||
|
||||
export const QUEUE_LABELS: Record<RoutedQueue, string> = {
|
||||
collections: 'Cobranza',
|
||||
claims: 'Siniestros',
|
||||
sales: 'Ventas',
|
||||
renewals: 'Renovaciones',
|
||||
operations: 'Operaciones',
|
||||
open_pool: 'Pool Abierto',
|
||||
}
|
||||
|
||||
export const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
email: 'Email',
|
||||
phone_note: 'Nota de Llamada',
|
||||
internal_note: 'Nota Interna',
|
||||
system: 'Sistema',
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function channelIcon(ch: SupportChannel): string {
|
||||
return CHANNEL_ICONS[ch] ?? 'i-heroicons-question-mark-circle'
|
||||
}
|
||||
|
||||
export function tierBadgeClass(tier: RoutingTier): string {
|
||||
switch (tier) {
|
||||
case 'tier1_auto': return 'sp-tier-1'
|
||||
case 'tier2_rule': return 'sp-tier-2'
|
||||
case 'tier3_open': return 'sp-tier-3'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('es-PA', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function fmtTime(d: string): string {
|
||||
return new Date(d).toLocaleTimeString('es-PA', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function fmtDateTime(d: string): string {
|
||||
return `${fmtDate(d)} ${fmtTime(d)}`
|
||||
}
|
||||
|
||||
// ─── Mock Tickets (List) ─────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_SUPPORT_TICKETS: SupportTicket[] = [
|
||||
{
|
||||
id: 'SP-001',
|
||||
subject: 'Consulta sobre pago pendiente de póliza auto',
|
||||
channel: 'whatsapp',
|
||||
status: 'resolved',
|
||||
priority: 'medium',
|
||||
intentCategory: 'payment_inquiry',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'collections',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
policyId: 'POL-2024-4412',
|
||||
policyNumber: 'AUTO-2024-4412',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-01T09:15:00',
|
||||
updatedAt: '2026-04-01T14:30:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 45,
|
||||
messageCount: 6,
|
||||
lastMessagePreview: 'Perfecto, ya quedó registrado el pago. Gracias María Elena.',
|
||||
},
|
||||
{
|
||||
id: 'SP-002',
|
||||
subject: 'Reporte de accidente vehicular — Toyota RAV4',
|
||||
channel: 'email',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
intentCategory: 'claim_report',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'claims',
|
||||
customerId: 'cust-004',
|
||||
customerName: 'Luis Andrés Solís Calderón',
|
||||
policyId: 'POL-2022-2200',
|
||||
policyNumber: 'AUTO-2022-2200',
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-03T11:22:00',
|
||||
updatedAt: '2026-04-07T16:45:00',
|
||||
daysOpen: 5,
|
||||
slaPercent: 62,
|
||||
messageCount: 5,
|
||||
lastMessagePreview: 'Adjunto las fotos del daño al vehículo tomadas esta mañana.',
|
||||
},
|
||||
{
|
||||
id: 'SP-003',
|
||||
subject: 'Solicitud de certificado de seguro para banco',
|
||||
channel: 'walk_in',
|
||||
status: 'resolved',
|
||||
priority: 'low',
|
||||
intentCategory: 'certificate_request',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
policyId: 'POL-2023-3301',
|
||||
policyNumber: 'AUTO-2023-3301',
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-04T10:00:00',
|
||||
updatedAt: '2026-04-04T10:35:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 20,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Certificado generado y entregado en mano.',
|
||||
},
|
||||
{
|
||||
id: 'SP-004',
|
||||
subject: 'Cotización seguro auto para BMW X3 2025',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
intentCategory: 'sales_interest',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'sales',
|
||||
customerId: null,
|
||||
customerName: 'Número desconocido (+507 6789-0123)',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-07T08:30:00',
|
||||
updatedAt: '2026-04-07T08:30:00',
|
||||
daysOpen: 1,
|
||||
slaPercent: 35,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Buenas, cuánto sale un seguro todo riesgo para un BMW X3 2025?',
|
||||
},
|
||||
{
|
||||
id: 'SP-005',
|
||||
subject: 'Queja por demora en proceso de siniestro CLM-0048',
|
||||
channel: 'email',
|
||||
status: 'pending_customer',
|
||||
priority: 'high',
|
||||
intentCategory: 'complaint',
|
||||
routingTier: 'tier3_open',
|
||||
routedQueue: 'open_pool',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
policyId: 'POL-2024-4412',
|
||||
policyNumber: 'AUTO-2024-4412',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-02T15:00:00',
|
||||
updatedAt: '2026-04-06T09:20:00',
|
||||
daysOpen: 6,
|
||||
slaPercent: 88,
|
||||
messageCount: 7,
|
||||
lastMessagePreview: 'Estamos pendientes de su respuesta con los documentos adicionales.',
|
||||
},
|
||||
{
|
||||
id: 'SP-006',
|
||||
subject: 'Consulta plan salud colectivo — Grupo Agrícola del Sur',
|
||||
channel: 'web_form',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
intentCategory: 'policy_question',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-010',
|
||||
customerName: 'Fernando Arias Blanco',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-06T14:10:00',
|
||||
updatedAt: '2026-04-07T11:00:00',
|
||||
daysOpen: 2,
|
||||
slaPercent: 50,
|
||||
messageCount: 4,
|
||||
lastMessagePreview: 'Le envío las opciones de cobertura para su grupo.',
|
||||
},
|
||||
{
|
||||
id: 'SP-007',
|
||||
subject: 'Mensaje ambiguo — "necesito ayuda con mi seguro"',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
intentCategory: 'general',
|
||||
routingTier: 'tier3_open',
|
||||
routedQueue: 'open_pool',
|
||||
customerId: null,
|
||||
customerName: 'Número desconocido (+507 6234-5678)',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: null,
|
||||
createdAt: '2026-04-08T07:45:00',
|
||||
updatedAt: '2026-04-08T07:45:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 15,
|
||||
messageCount: 1,
|
||||
lastMessagePreview: 'Hola buenos días, necesito ayuda con mi seguro, me pueden atender?',
|
||||
},
|
||||
{
|
||||
id: 'SP-008',
|
||||
subject: 'Endoso — agregar conductor a póliza auto',
|
||||
channel: 'email',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
intentCategory: 'endorsement',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-004',
|
||||
customerName: 'Luis Andrés Solís Calderón',
|
||||
policyId: 'POL-2022-2201',
|
||||
policyNumber: 'AUTO-2022-2201',
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-05T09:00:00',
|
||||
updatedAt: '2026-04-07T15:30:00',
|
||||
daysOpen: 3,
|
||||
slaPercent: 55,
|
||||
messageCount: 5,
|
||||
lastMessagePreview: 'Necesitamos la cédula y licencia del conductor adicional.',
|
||||
},
|
||||
{
|
||||
id: 'SP-009',
|
||||
subject: 'URGENTE — Choque frontal Toyota Hilux, requiere grúa',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
intentCategory: 'claim_report',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'claims',
|
||||
customerId: 'cust-010',
|
||||
customerName: 'Fernando Arias Blanco',
|
||||
policyId: 'POL-2020-1100',
|
||||
policyNumber: 'AUTO-2020-1100',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-08T06:15:00',
|
||||
updatedAt: '2026-04-08T06:45:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 40,
|
||||
messageCount: 4,
|
||||
lastMessagePreview: 'Ya coordiné la grúa, llega en 20 minutos a tu ubicación.',
|
||||
},
|
||||
{
|
||||
id: 'SP-010',
|
||||
subject: 'Seguimiento renovación póliza vida — vence 30 abril',
|
||||
channel: 'phone',
|
||||
status: 'resolved',
|
||||
priority: 'low',
|
||||
intentCategory: 'policy_question',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'renewals',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
policyId: 'POL-2023-3301',
|
||||
policyNumber: 'AUTO-2023-3301',
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-03T16:00:00',
|
||||
updatedAt: '2026-04-04T09:00:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 30,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Cliente confirmó renovación. Proceso completado.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Mock Ticket Details ─────────────────────────────────────────────────────
|
||||
|
||||
const sp001Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[0],
|
||||
customerEmail: 'maria.perez@gmail.com',
|
||||
customerPhone: '+507 6123-4567',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2024-4412', number: 'AUTO-2024-4412', carrier: 'ASSA', lob: 'Auto', status: 'Active', renewal: '2027-03-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-001-1', type: 'whatsapp', direction: 'inbound', from: 'María Elena Pérez', to: null, subject: null, body: 'Hola buenas, tengo una consulta sobre mi pago. Me llegó un recibo pero ya hice la transferencia la semana pasada. Pueden verificar?', timestamp: '2026-04-01T09:15:00', aiDigest: 'Customer inquiring about payment status — claims transfer was already made.' },
|
||||
{ id: 'msg-001-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Ticket creado automáticamente. Intent detectado: Consulta de Pago (92% confianza). Enrutado a Cobranza.', timestamp: '2026-04-01T09:15:05', aiDigest: null },
|
||||
{ id: 'msg-001-3', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'María Elena Pérez', subject: null, body: 'Buenos días María Elena! Déjame verificar con el departamento de cobranza. ¿Me puede compartir el comprobante de la transferencia?', timestamp: '2026-04-01T09:45:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
{ id: 'msg-001-4', type: 'whatsapp', direction: 'inbound', from: 'María Elena Pérez', to: null, subject: null, body: 'Claro, aquí está el comprobante. Fue el 25 de marzo por $460.', timestamp: '2026-04-01T10:02:00', aiDigest: null },
|
||||
{ id: 'msg-001-5', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Verificado con contabilidad. Pago recibido el 26/03 — recibo cruzado con período anterior. Se ajusta en sistema.', timestamp: '2026-04-01T13:00:00', aiDigest: null },
|
||||
{ id: 'msg-001-6', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'María Elena Pérez', subject: null, body: 'Perfecto, ya quedó registrado el pago. Gracias María Elena. El recibo que le llegó era del mes anterior, ya se corrigió. Cualquier cosa me avisa!', timestamp: '2026-04-01T14:30:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
],
|
||||
aiSummary: 'Cliente consultó sobre un pago que ya había realizado. Se verificó con contabilidad que la transferencia fue recibida y el recibo cruzado con un período anterior fue corregido. Ticket resuelto satisfactoriamente.',
|
||||
aiSuggestedIntent: 'payment_inquiry',
|
||||
aiConfidence: 0.92,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — número registrado: +507 6123-4567', timestamp: '2026-04-01T09:15:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: María Elena Pérez Solano (cust-001)', timestamp: '2026-04-01T09:15:02' },
|
||||
{ step: 'Intent Classification', result: 'payment_inquiry (92% confidence) — keywords: "pago", "recibo", "transferencia"', timestamp: '2026-04-01T09:15:03' },
|
||||
{ step: 'Rule Fired', result: 'Payment intent → Collections queue', timestamp: '2026-04-01T09:15:04' },
|
||||
{ step: 'Agent Assignment', result: 'Carlos Villalba (assigned broker for cust-001)', timestamp: '2026-04-01T09:15:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp002Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[1],
|
||||
customerEmail: 'luis.solis@outlook.com',
|
||||
customerPhone: '+507 6555-1234',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2022-2200', number: 'AUTO-2022-2200', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
{ id: 'POL-2022-2201', number: 'AUTO-2022-2201', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
],
|
||||
linkedClaims: [
|
||||
{ id: 'CLM-0048', type: 'Collision', status: 'Under Review' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 'msg-002-1', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Reporte de accidente — BMW X5 SJO-2200', body: 'Estimados, les informo que tuve un accidente vehicular el día de hoy en la autopista Próspero Fernández. Mi vehículo BMW X5 placas SJO-2200 sufrió daños frontales tras una colisión. Adjunto fotos del daño. Por favor indiquen los pasos a seguir para el reclamo.', timestamp: '2026-04-03T11:22:00', aiDigest: 'Customer reporting vehicle collision on Próspero Fernández highway. BMW X5, front damage. Requesting claim process guidance.' },
|
||||
{ id: 'msg-002-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Ticket creado. Intent: claim_report (97%). Enrutado a Siniestros. Claim CLM-0048 vinculado automáticamente.', timestamp: '2026-04-03T11:22:10', aiDigest: null },
|
||||
{ id: 'msg-002-3', type: 'email', direction: 'outbound', from: 'maria.ortiz@seguros.com', to: 'luis.solis@outlook.com', subject: 'Re: Reporte de accidente — BMW X5 SJO-2200', body: 'Estimado Luis, lamentamos mucho el accidente. Ya creamos su reclamo bajo el número CLM-0048. Necesitamos:\n\n1. Parte policial (si aplica)\n2. Fotos adicionales del otro vehículo\n3. Datos del otro conductor\n\nQuedo atenta.', timestamp: '2026-04-03T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-002-4', type: 'internal_note', direction: 'internal', from: 'María Fernanda Ortiz', to: null, subject: null, body: 'Cliente VIP — 2 pólizas activas, $5,800/yr premium. Priorizar este caso. Contactar ajustador para inspección rápida.', timestamp: '2026-04-04T08:30:00', aiDigest: null },
|
||||
{ id: 'msg-002-5', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Re: Reporte de accidente — BMW X5 SJO-2200', body: 'Adjunto las fotos del daño al vehículo tomadas esta mañana. El parte policial lo tengo listo para mañana. El otro conductor no quiso dar sus datos, pero tengo la placa.', timestamp: '2026-04-07T16:45:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Accidente vehicular reportado por correo. BMW X5, daños frontales por colisión en autopista. Claim CLM-0048 creado y vinculado. Pendiente: parte policial y datos del otro conductor. Cliente VIP con 2 pólizas — priorizar.',
|
||||
aiSuggestedIntent: 'claim_report',
|
||||
aiConfidence: 0.97,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — luis.solis@outlook.com', timestamp: '2026-04-03T11:22:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Luis Andrés Solís Calderón (cust-004)', timestamp: '2026-04-03T11:22:02' },
|
||||
{ step: 'Intent Classification', result: 'claim_report (97%) — keywords: "accidente", "daños", "colisión", "reclamo"', timestamp: '2026-04-03T11:22:03' },
|
||||
{ step: 'Rule Fired', result: 'Claim intent → Claims queue', timestamp: '2026-04-03T11:22:04' },
|
||||
{ step: 'Policy Link', result: 'Auto-linked POL-2022-2200 (BMW X5, matching plate SJO-2200)', timestamp: '2026-04-03T11:22:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp003Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[2],
|
||||
customerEmail: 'roberto.jimenez@gmail.com',
|
||||
customerPhone: '+507 6222-8899',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2023-3301', number: 'AUTO-2023-3301', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-04-01' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-003-1', type: 'phone_note', direction: 'inbound', from: 'Roberto Jiménez Mora', to: null, subject: null, body: 'Cliente se presentó en oficina solicitando certificado de seguro vigente para trámite bancario. Necesita documento hoy.', timestamp: '2026-04-04T10:00:00', aiDigest: null },
|
||||
{ id: 'msg-003-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Cliente conocido (cust-002). Tier 1 auto-route: solicitud de certificado → Operaciones. Auto-fulfill iniciado.', timestamp: '2026-04-04T10:00:05', aiDigest: null },
|
||||
{ id: 'msg-003-3', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Certificado generado y entregado en mano. Cliente satisfecho. Ticket cerrado.', timestamp: '2026-04-04T10:35:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Solicitud de certificado de seguro vigente para trámite bancario. Cliente conocido, auto-enrutado a operaciones. Certificado generado y entregado presencialmente en 35 minutos.',
|
||||
aiSuggestedIntent: 'certificate_request',
|
||||
aiConfidence: 0.99,
|
||||
routingTrace: [
|
||||
{ step: 'Walk-in Registration', result: 'Registered by front desk', timestamp: '2026-04-04T10:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Roberto Jiménez Mora (cust-002)', timestamp: '2026-04-04T10:00:02' },
|
||||
{ step: 'Intent Classification', result: 'certificate_request (99%) — direct request', timestamp: '2026-04-04T10:00:03' },
|
||||
{ step: 'Tier 1 Auto-Fulfill', result: 'Certificate generation triggered for POL-2023-3301', timestamp: '2026-04-04T10:00:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp004Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[3],
|
||||
customerEmail: null,
|
||||
customerPhone: '+507 6789-0123',
|
||||
linkedPolicies: [],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-004-1', type: 'whatsapp', direction: 'inbound', from: '+507 6789-0123', to: null, subject: null, body: 'Buenas, cuánto sale un seguro todo riesgo para un BMW X3 2025? Nuevo, recién importado.', timestamp: '2026-04-07T08:30:00', aiDigest: 'Unknown number asking for comprehensive auto insurance quote for new BMW X3 2025.' },
|
||||
{ id: 'msg-004-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Número no registrado. Intent: sales_interest (89%). Enrutado a Ventas.', timestamp: '2026-04-07T08:30:05', aiDigest: null },
|
||||
{ id: 'msg-004-3', type: 'whatsapp', direction: 'outbound', from: 'María Fernanda Ortiz', to: '+507 6789-0123', subject: null, body: 'Buenos días! Con gusto le cotizamos. Para darle las mejores opciones necesito:\n\n1. Nombre completo\n2. Cédula o pasaporte\n3. Valor del vehículo según factura\n4. Uso (particular o comercial)\n\nQuedo atenta!', timestamp: '2026-04-07T09:15:00', aiDigest: null, deliveryStatus: 'delivered' },
|
||||
],
|
||||
aiSummary: 'Lead entrante vía WhatsApp — persona desconocida consultando precio de seguro todo riesgo para BMW X3 2025 nuevo. Asignado a ventas. Pendiente respuesta del prospecto con datos personales.',
|
||||
aiSuggestedIntent: 'sales_interest',
|
||||
aiConfidence: 0.89,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6789-0123', timestamp: '2026-04-07T08:30:01' },
|
||||
{ step: 'Customer Match', result: 'No match — unknown number', timestamp: '2026-04-07T08:30:02' },
|
||||
{ step: 'Intent Classification', result: 'sales_interest (89%) — keywords: "cuánto sale", "seguro todo riesgo", "nuevo"', timestamp: '2026-04-07T08:30:03' },
|
||||
{ step: 'Rule Fired', result: 'Sales intent → Sales queue', timestamp: '2026-04-07T08:30:04' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp005Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[4],
|
||||
customerEmail: 'maria.perez@gmail.com',
|
||||
customerPhone: '+507 6123-4567',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2024-4412', number: 'AUTO-2024-4412', carrier: 'ASSA', lob: 'Auto', status: 'Active', renewal: '2027-03-15' },
|
||||
],
|
||||
linkedClaims: [
|
||||
{ id: 'CLM-0048', type: 'Collision', status: 'Under Review' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 'msg-005-1', type: 'email', direction: 'inbound', from: 'maria.perez@gmail.com', to: 'soporte@seguros.com', subject: 'QUEJA — demora inaceptable en siniestro CLM-0048', body: 'Estimados, llevo más de 3 semanas esperando respuesta sobre mi siniestro CLM-0048. He llamado varias veces y nadie me da una respuesta clara. Esto es inaceptable. Necesito que alguien con autoridad me contacte hoy mismo o procederé con una queja formal ante la Superintendencia.', timestamp: '2026-04-02T15:00:00', aiDigest: 'Frustrated customer filing formal complaint about claim CLM-0048 delays. Threatening regulatory escalation.' },
|
||||
{ id: 'msg-005-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent ambiguo: complaint (76%) / claim_report (18%). No cumple regla de enrutamiento directo. Enviado a Pool Abierto.', timestamp: '2026-04-02T15:00:05', aiDigest: null },
|
||||
{ id: 'msg-005-3', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Asigno este ticket a mí. Conozco a la clienta — tiene razón, el claim lleva demasiado tiempo. Voy a escalar con el ajustador de ASSA directamente.', timestamp: '2026-04-02T16:30:00', aiDigest: null },
|
||||
{ id: 'msg-005-4', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — demora inaceptable en siniestro CLM-0048', body: 'Estimada María Elena, le pido disculpas sinceras por la demora. He escalado personalmente su caso con el ajustador de ASSA y tenemos reunión mañana a primera hora. Le daré un update antes del mediodía del viernes. Entiendo su frustración y estoy comprometido a resolverlo.', timestamp: '2026-04-02T17:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-5', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Reunión con ajustador ASSA — dicen que falta un documento del taller. Ya lo solicité. Debería llegar mañana.', timestamp: '2026-04-04T10:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-6', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — Actualización siniestro CLM-0048', body: 'María Elena, actualización: ASSA necesita un documento adicional del taller donde se hizo la reparación. ¿Podría solicitarle al taller el presupuesto final firmado y sellado? Con eso podemos cerrar la revisión esta semana.', timestamp: '2026-04-04T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-7', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — Seguimiento documentos pendientes', body: 'Hola María Elena, solo un seguimiento amable — estamos pendientes de su respuesta con los documentos adicionales del taller para avanzar con ASSA. Quedo a la orden.', timestamp: '2026-04-06T09:20:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Queja formal por demora en siniestro CLM-0048 (colisión). Cliente amenaza con escalar a Superintendencia. Broker Carlos Villalba se asignó y escaló con ajustador ASSA. Pendiente: documento del taller (presupuesto firmado) para cerrar revisión. Estado: esperando respuesta del cliente.',
|
||||
aiSuggestedIntent: 'complaint',
|
||||
aiConfidence: 0.76,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — maria.perez@gmail.com', timestamp: '2026-04-02T15:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: María Elena Pérez Solano (cust-001)', timestamp: '2026-04-02T15:00:02' },
|
||||
{ step: 'Intent Classification', result: 'Ambiguous — complaint (76%), claim_report (18%)', timestamp: '2026-04-02T15:00:03' },
|
||||
{ step: 'No Rule Match', result: 'Confidence below threshold for auto-routing', timestamp: '2026-04-02T15:00:04' },
|
||||
{ step: 'Routed to Open Pool', result: 'Tier 3 — awaiting manual triage', timestamp: '2026-04-02T15:00:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp006Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[5],
|
||||
customerEmail: 'fernando.arias@transportesnorte.cr',
|
||||
customerPhone: '+507 6888-4444',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2020-1100', number: 'AUTO-2020-1100', carrier: 'INS', lob: 'Auto', status: 'Active', renewal: '2026-12-15' },
|
||||
{ id: 'POL-2021-2200', number: 'HOME-2021-2200', carrier: 'ASSA', lob: 'Home', status: 'Lapsed', renewal: '—' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-006-1', type: 'email', direction: 'inbound', from: 'fernando.arias@transportesnorte.cr', to: 'soporte@seguros.com', subject: 'Consulta — plan salud colectivo para nuestro grupo', body: 'Buenos días, soy Fernando Arias de Transportes del Norte. Quiero explorar opciones de seguro de salud colectivo para nuestros empleados (aprox 50 personas). ¿Qué opciones tienen disponibles y cuál sería el proceso?', timestamp: '2026-04-06T14:10:00', aiDigest: 'Existing corporate customer inquiring about group health insurance for ~50 employees.' },
|
||||
{ id: 'msg-006-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Cliente conocido (cust-010). Web form con LOB=Health. Tier 1: auto-routed a broker asignado (Ana R.).', timestamp: '2026-04-06T14:10:05', aiDigest: null },
|
||||
{ id: 'msg-006-3', type: 'email', direction: 'outbound', from: 'ana.r@seguros.com', to: 'fernando.arias@transportesnorte.cr', subject: 'Re: Consulta — plan salud colectivo', body: 'Estimado Fernando, qué gusto saludarlo! Con mucho gusto le ayudamos con el plan colectivo de salud. Tenemos opciones con Vida Plena, Salud Global e Integral Medical. Le envío las opciones de cobertura para su grupo con los detalles de cada plan.', timestamp: '2026-04-06T16:00:00', aiDigest: null },
|
||||
{ id: 'msg-006-4', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Cliente ya tiene 2 pólizas (1 activa auto, 1 lapsed home). Oportunidad de cross-sell. Preparar propuesta colectiva con las 3 aseguradoras de salud.', timestamp: '2026-04-07T11:00:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Cliente corporativo existente (Transportes del Norte) consultando sobre plan de salud colectivo para ~50 empleados. Auto-enrutado por Tier 1 a broker asignado. Oportunidad de cross-sell identificada — ya tiene póliza auto activa.',
|
||||
aiSuggestedIntent: 'policy_question',
|
||||
aiConfidence: 0.85,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Web Form — LOB: Health', timestamp: '2026-04-06T14:10:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Fernando Arias Blanco (cust-010)', timestamp: '2026-04-06T14:10:02' },
|
||||
{ step: 'Tier 1 Auto-Route', result: 'Known customer + LOB form → assigned broker', timestamp: '2026-04-06T14:10:03' },
|
||||
{ step: 'Agent Assignment', result: 'Ana R. (assigned broker for cust-010)', timestamp: '2026-04-06T14:10:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp007Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[6],
|
||||
customerEmail: null,
|
||||
customerPhone: '+507 6234-5678',
|
||||
linkedPolicies: [],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-007-1', type: 'whatsapp', direction: 'inbound', from: '+507 6234-5678', to: null, subject: null, body: 'Hola buenos días, necesito ayuda con mi seguro, me pueden atender?', timestamp: '2026-04-08T07:45:00', aiDigest: 'Ambiguous request — "need help with my insurance". No specific intent detectable. Unknown customer.' },
|
||||
],
|
||||
aiSummary: 'Mensaje ambiguo de número desconocido. No se puede determinar intención específica. Requiere triage manual por operador del pool abierto.',
|
||||
aiSuggestedIntent: 'general',
|
||||
aiConfidence: 0.34,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6234-5678', timestamp: '2026-04-08T07:45:01' },
|
||||
{ step: 'Customer Match', result: 'No match — unknown number', timestamp: '2026-04-08T07:45:02' },
|
||||
{ step: 'Intent Classification', result: 'Ambiguous — general (34%), policy_question (28%), sales_interest (22%)', timestamp: '2026-04-08T07:45:03' },
|
||||
{ step: 'No Rule Match', result: 'Low confidence across all intents', timestamp: '2026-04-08T07:45:04' },
|
||||
{ step: 'Routed to Open Pool', result: 'Tier 3 — unassigned, awaiting manual triage', timestamp: '2026-04-08T07:45:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp008Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[7],
|
||||
customerEmail: 'luis.solis@outlook.com',
|
||||
customerPhone: '+507 6555-1234',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2022-2201', number: 'AUTO-2022-2201', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-008-1', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Agregar conductor adicional a póliza AUTO-2022-2201', body: 'Estimados, necesito agregar a mi esposa como conductor adicional en mi póliza AUTO-2022-2201 (Mercedes GLC). ¿Qué documentos necesitan y cuál es el costo adicional?', timestamp: '2026-04-05T09:00:00', aiDigest: 'Customer requesting driver addition endorsement to auto policy. Asking about requirements and cost.' },
|
||||
{ id: 'msg-008-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent: endorsement (94%). Enrutado a Operaciones.', timestamp: '2026-04-05T09:00:05', aiDigest: null },
|
||||
{ id: 'msg-008-3', type: 'email', direction: 'outbound', from: 'ana.r@seguros.com', to: 'luis.solis@outlook.com', subject: 'Re: Agregar conductor adicional a póliza AUTO-2022-2201', body: 'Estimado Luis, con gusto procesamos el endoso. Necesitamos:\n\n1. Cédula de su esposa (copia)\n2. Licencia de conducir vigente (copia)\n3. Fecha de nacimiento\n\nEl costo adicional depende del perfil — generalmente entre $50-$120/año. Le confirmo el monto exacto una vez tenga los documentos.', timestamp: '2026-04-05T11:30:00', aiDigest: null },
|
||||
{ id: 'msg-008-4', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Re: Agregar conductor adicional', body: 'Adjunto cédula y licencia de mi esposa Carolina Méndez de Solís. Su fecha de nacimiento es 15 de mayo de 1988.', timestamp: '2026-04-06T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-008-5', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Documentos recibidos. Solicitando cotización de endoso a Qualitas. Pendiente respuesta del carrier para confirmar monto.', timestamp: '2026-04-07T15:30:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Solicitud de endoso para agregar conductor adicional (esposa) a póliza auto Mercedes GLC. Documentos recibidos (cédula + licencia). Pendiente cotización del carrier Qualitas para confirmar costo adicional.',
|
||||
aiSuggestedIntent: 'endorsement',
|
||||
aiConfidence: 0.94,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — luis.solis@outlook.com', timestamp: '2026-04-05T09:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Luis Andrés Solís Calderón (cust-004)', timestamp: '2026-04-05T09:00:02' },
|
||||
{ step: 'Intent Classification', result: 'endorsement (94%) — keywords: "agregar", "conductor adicional", "endoso"', timestamp: '2026-04-05T09:00:03' },
|
||||
{ step: 'Rule Fired', result: 'Endorsement intent → Operations queue', timestamp: '2026-04-05T09:00:04' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp009Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[8],
|
||||
customerEmail: 'fernando.arias@transportesnorte.cr',
|
||||
customerPhone: '+507 6888-4444',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2020-1100', number: 'AUTO-2020-1100', carrier: 'INS', lob: 'Auto', status: 'Active', renewal: '2026-12-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-009-1', type: 'whatsapp', direction: 'inbound', from: 'Fernando Arias', to: null, subject: null, body: 'URGENTE Carlos!! Acabo de tener un choque frontal con la Hilux en la vía Interamericana. El carro no enciende y necesito grúa YA. Estoy en el km 42 dirección David.', timestamp: '2026-04-08T06:15:00', aiDigest: 'URGENT: Known customer reporting front collision with Toyota Hilux. Vehicle disabled, requesting tow truck. Location: km 42 Interamericana highway.' },
|
||||
{ id: 'msg-009-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'URGENTE — Cliente conocido (cust-010). Intent: claim_report (99%). Tier 1 auto-route a broker asignado. Prioridad: URGENTE.', timestamp: '2026-04-08T06:15:05', aiDigest: null },
|
||||
{ id: 'msg-009-3', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'Fernando Arias', subject: null, body: 'Fernando tranquilo, ya estoy en eso. ¿Estás bien? ¿Hay heridos? Voy a coordinar la grúa ahora mismo.', timestamp: '2026-04-08T06:20:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
{ id: 'msg-009-4', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'Fernando Arias', subject: null, body: 'Ya coordiné la grúa, llega en 20 minutos a tu ubicación. INS tiene cobertura de asistencia en carretera incluida en tu póliza. ¿Necesitas que llame a alguien más?', timestamp: '2026-04-08T06:45:00', aiDigest: null, deliveryStatus: 'delivered' },
|
||||
],
|
||||
aiSummary: 'Emergencia: choque frontal con Toyota Hilux en vía Interamericana km 42. Vehículo inhabilitado. Broker coordinó grúa inmediatamente (20 min ETA). Asistencia en carretera cubierta por póliza INS. Pendiente: confirmar estado del conductor y crear claim formal.',
|
||||
aiSuggestedIntent: 'claim_report',
|
||||
aiConfidence: 0.99,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6888-4444 (Fernando Arias)', timestamp: '2026-04-08T06:15:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Fernando Arias Blanco (cust-010)', timestamp: '2026-04-08T06:15:02' },
|
||||
{ step: 'Intent Classification', result: 'claim_report (99%) — keywords: "choque", "grúa", "URGENTE"', timestamp: '2026-04-08T06:15:03' },
|
||||
{ step: 'Priority Escalation', result: 'Auto-set URGENT — emergency keywords detected', timestamp: '2026-04-08T06:15:04' },
|
||||
{ step: 'Tier 1 Auto-Route', result: 'Known customer → assigned broker Carlos Villalba', timestamp: '2026-04-08T06:15:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp010Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[9],
|
||||
customerEmail: 'roberto.jimenez@gmail.com',
|
||||
customerPhone: '+507 6222-8899',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2023-3301', number: 'AUTO-2023-3301', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-04-01' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-010-1', type: 'phone_note', direction: 'inbound', from: 'Roberto Jiménez Mora', to: null, subject: null, body: 'Cliente llamó consultando sobre su renovación que vence el 30 de abril. Quiere saber si hay cambio en prima y si puede agregar cobertura de robo.', timestamp: '2026-04-03T16:00:00', aiDigest: 'Customer calling about upcoming renewal (Apr 30). Asking about premium changes and adding theft coverage.' },
|
||||
{ id: 'msg-010-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent: policy_question (78%) / sales_interest (15%). Keyword "renovación" → Renewals queue.', timestamp: '2026-04-03T16:00:05', aiDigest: null },
|
||||
{ id: 'msg-010-3', type: 'internal_note', direction: 'internal', from: 'María Fernanda Ortiz', to: null, subject: null, body: 'Cliente confirmó renovación con cobertura adicional de robo. Prima aumenta $180/año. Proceso completado — renovación efectiva 1 mayo 2027.', timestamp: '2026-04-04T09:00:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Seguimiento telefónico sobre renovación próxima (30 abril). Cliente confirmó renovación con adición de cobertura de robo (+$180/año). Proceso completado.',
|
||||
aiSuggestedIntent: 'policy_question',
|
||||
aiConfidence: 0.78,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Phone call logged', timestamp: '2026-04-03T16:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Roberto Jiménez Mora (cust-002)', timestamp: '2026-04-03T16:00:02' },
|
||||
{ step: 'Intent Classification', result: 'policy_question (78%) — keyword: "renovación"', timestamp: '2026-04-03T16:00:03' },
|
||||
{ step: 'Rule Fired', result: 'Renewal keyword → Renewals queue', timestamp: '2026-04-03T16:00:04' },
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_TICKET_DETAILS: Record<string, SupportTicketDetail> = {
|
||||
'SP-001': sp001Detail,
|
||||
'SP-002': sp002Detail,
|
||||
'SP-003': sp003Detail,
|
||||
'SP-004': sp004Detail,
|
||||
'SP-005': sp005Detail,
|
||||
'SP-006': sp006Detail,
|
||||
'SP-007': sp007Detail,
|
||||
'SP-008': sp008Detail,
|
||||
'SP-009': sp009Detail,
|
||||
'SP-010': sp010Detail,
|
||||
}
|
||||
|
||||
// ─── Mock Routing Rules ──────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_ROUTING_RULES: RoutingRule[] = [
|
||||
// Tier 1
|
||||
{ id: 'rule-001', tier: 'tier1_auto', type: 'customer_match', name: 'Known Customer → Assigned Broker', condition: 'When incoming message matches a registered customer, route to their assigned broker', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 1 },
|
||||
{ id: 'rule-002', tier: 'tier1_auto', type: 'lob_specialist', name: 'Web Form LOB → Specialist', condition: 'Web form with LOB selected routes to LOB specialist team', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 2 },
|
||||
{ id: 'rule-003', tier: 'tier1_auto', type: 'doc_auto_fulfill', name: 'Certificate Auto-Fulfill', condition: 'Certificate requests from known customers auto-generate and deliver', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 3 },
|
||||
// Tier 2
|
||||
{ id: 'rule-004', tier: 'tier2_rule', type: 'keyword_intent', name: 'Payment Keywords → Collections', condition: 'Keywords: pago, factura, cobro, recibo, transferencia, mora', targetQueue: 'collections', targetAgent: null, enabled: true, priority: 10 },
|
||||
{ id: 'rule-005', tier: 'tier2_rule', type: 'keyword_intent', name: 'Claim Keywords → Claims', condition: 'Keywords: siniestro, accidente, robo, daño, choque, grúa, colisión', targetQueue: 'claims', targetAgent: null, enabled: true, priority: 11 },
|
||||
{ id: 'rule-006', tier: 'tier2_rule', type: 'keyword_intent', name: 'Sales Keywords → Sales', condition: 'Keywords: cotización, seguro nuevo, precio, cuánto sale, cobertura', targetQueue: 'sales', targetAgent: null, enabled: true, priority: 12 },
|
||||
{ id: 'rule-007', tier: 'tier2_rule', type: 'keyword_intent', name: 'Renewal Keywords → Renewals', condition: 'Keywords: renovación, vencimiento, prórroga, vigencia', targetQueue: 'renewals', targetAgent: null, enabled: true, priority: 13 },
|
||||
{ id: 'rule-008', tier: 'tier2_rule', type: 'keyword_intent', name: 'Endorsement Keywords → Operations', condition: 'Keywords: endoso, agregar, modificar, cambio de beneficiario', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 14 },
|
||||
]
|
||||
20
app/data/pdf-field-mappings.json
Normal file
20
app/data/pdf-field-mappings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mappings": [
|
||||
{
|
||||
"catalogFormId": 33,
|
||||
"fields": {
|
||||
"full_name": "txtNombreCompleto",
|
||||
"document_id": "txtCedula"
|
||||
}
|
||||
},
|
||||
{
|
||||
"catalogFormId": 39,
|
||||
"fields": {
|
||||
"plate": "txtPlaca",
|
||||
"vin": "txtChasis",
|
||||
"declared_value": "numValor"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
214
app/data/quotes-overview.mock.ts
Normal file
214
app/data/quotes-overview.mock.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Mock pipeline data for Quotes overview (mission control). Replace with API when ready.
|
||||
*/
|
||||
|
||||
export type QuotePipelineScopeFilter = 'global' | 'corporate' | 'personal'
|
||||
|
||||
export type QuoteOverviewLob = 'auto' | 'health' | 'life' | 'general_risk' | 'custom'
|
||||
|
||||
export type QuotePipelineStageId = 'intake' | 'quoted' | 'proposal' | 'bind' | 'handoff'
|
||||
|
||||
/**
|
||||
* Unified pipeline stage IDs — mirrors PipelineStage from useSalesPipeline.
|
||||
* Used by the quotes overview kanban so both views share one vocabulary.
|
||||
*/
|
||||
export type UnifiedStageId =
|
||||
| 'customer'
|
||||
| 'get_quotes'
|
||||
| 'waiting_carriers'
|
||||
| 'present_quotes'
|
||||
| 'waiting_client'
|
||||
| 'solicitud'
|
||||
| 'emission'
|
||||
|
||||
export type MockPipelineQuote = {
|
||||
id: string
|
||||
customerLabel: string
|
||||
/** Corporate vs personal — drives pipeline scope filters */
|
||||
party: 'corporate' | 'personal'
|
||||
lob: QuoteOverviewLob
|
||||
/** Sub-path: single, comparativo, fleet, collective, etc. */
|
||||
pathLabel: string
|
||||
stage: QuotePipelineStageId
|
||||
/** Unified pipeline stage — same vocabulary as sales pipeline */
|
||||
unifiedStage: UnifiedStageId
|
||||
owner: string
|
||||
formsDone: number
|
||||
formsTotal: number
|
||||
/** Customer has been notified / has portal activity (mock) */
|
||||
customerInformed: boolean
|
||||
}
|
||||
|
||||
export const QUOTE_PIPELINE_STAGES: { id: QuotePipelineStageId; label: string; hint: string }[] = [
|
||||
{ id: 'intake', label: 'Customer Profile', hint: 'Qualify & rate inputs' },
|
||||
{ id: 'quoted', label: 'Quote Prep', hint: 'Options with carrier' },
|
||||
{ id: 'proposal', label: 'Acceptance Pending', hint: 'Out to customer' },
|
||||
{ id: 'bind', label: 'Emission Pending', hint: 'Submit & conditions' },
|
||||
{ id: 'handoff', label: 'Going Live!', hint: 'Policy / solicitud' }
|
||||
]
|
||||
|
||||
/** Unified pipeline stages — same as sales pipeline. Used in overview chart. */
|
||||
export const UNIFIED_PIPELINE_STAGES: { id: UnifiedStageId; label: string; isWaiting: boolean }[] = [
|
||||
{ id: 'customer', label: 'Customer', isWaiting: false },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', isWaiting: false },
|
||||
{ id: 'waiting_carriers', label: 'Awaiting Carriers', isWaiting: true },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', isWaiting: false },
|
||||
{ id: 'waiting_client', label: 'Awaiting Client', isWaiting: true },
|
||||
{ id: 'solicitud', label: 'Solicitud', isWaiting: false },
|
||||
{ id: 'emission', label: 'Emission', isWaiting: false },
|
||||
]
|
||||
|
||||
export const QUOTE_LOB_OPTIONS: { value: QuoteOverviewLob | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'All lines' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'health', label: 'Health' },
|
||||
{ value: 'life', label: 'Life' },
|
||||
{ value: 'general_risk', label: 'General risk' },
|
||||
{ value: 'custom', label: 'Custom' }
|
||||
]
|
||||
|
||||
export const MOCK_PIPELINE_QUOTES: MockPipelineQuote[] = [
|
||||
{
|
||||
id: 'q-101',
|
||||
customerLabel: 'Transportes Delta S.A.',
|
||||
party: 'corporate',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Fleet',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'waiting_carriers',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 2,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-102',
|
||||
customerLabel: 'María Fernández',
|
||||
party: 'personal',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Comparativo',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'present_quotes',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 4,
|
||||
formsTotal: 4,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-103',
|
||||
customerLabel: 'J. Pérez',
|
||||
party: 'personal',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Single',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'customer',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 0,
|
||||
formsTotal: 3,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-201',
|
||||
customerLabel: 'Clínica Norte',
|
||||
party: 'corporate',
|
||||
lob: 'health',
|
||||
pathLabel: 'Collective',
|
||||
stage: 'bind',
|
||||
unifiedStage: 'solicitud',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 6,
|
||||
formsTotal: 8,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-202',
|
||||
customerLabel: 'Familia Ortega',
|
||||
party: 'personal',
|
||||
lob: 'health',
|
||||
pathLabel: 'Family',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'get_quotes',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 3,
|
||||
formsTotal: 6,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-203',
|
||||
customerLabel: 'Startup Labs',
|
||||
party: 'corporate',
|
||||
lob: 'health',
|
||||
pathLabel: 'Travel',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'customer',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 1,
|
||||
formsTotal: 4,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-301',
|
||||
customerLabel: 'Holdings Centro',
|
||||
party: 'corporate',
|
||||
lob: 'life',
|
||||
pathLabel: 'Key person',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'waiting_client',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 2,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-302',
|
||||
customerLabel: 'Carlos Méndez',
|
||||
party: 'personal',
|
||||
lob: 'life',
|
||||
pathLabel: 'Individual',
|
||||
stage: 'handoff',
|
||||
unifiedStage: 'emission',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 5,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-401',
|
||||
customerLabel: 'Retail Plaza',
|
||||
party: 'corporate',
|
||||
lob: 'general_risk',
|
||||
pathLabel: 'Corporate',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'waiting_carriers',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 3,
|
||||
formsTotal: 7,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-402',
|
||||
customerLabel: 'Ana Ríos',
|
||||
party: 'personal',
|
||||
lob: 'general_risk',
|
||||
pathLabel: 'Personal',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'get_quotes',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 1,
|
||||
formsTotal: 4,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-501',
|
||||
customerLabel: 'Phone-in — manual rate',
|
||||
party: 'personal',
|
||||
lob: 'custom',
|
||||
pathLabel: 'Manual entry',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'present_quotes',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 0,
|
||||
formsTotal: 2,
|
||||
customerInformed: false
|
||||
}
|
||||
]
|
||||
35
app/data/roles-seguros.ts
Normal file
35
app/data/roles-seguros.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RoleRow, SegurosPermissionKey } from '~/types/roles'
|
||||
|
||||
function all(v: boolean): Record<SegurosPermissionKey, boolean> {
|
||||
return {
|
||||
profile: v,
|
||||
portfolio: v,
|
||||
layers: v,
|
||||
tasks: v,
|
||||
billing: v,
|
||||
analytics: v,
|
||||
support: v
|
||||
}
|
||||
}
|
||||
|
||||
export const ROLES_SEGUROS_SEED: RoleRow[] = [
|
||||
{ id: 5, description: 'Agente de Ventas', active: true, seguros: all(true) },
|
||||
{ id: 4, description: 'Supervisor de Ventas', active: true, seguros: all(true) },
|
||||
{ id: 3, description: 'Configurador', active: true, seguros: all(false) },
|
||||
{ id: 2, description: 'Supervisor', active: true, seguros: all(false) },
|
||||
{ id: 1, description: 'Superadministrator', active: true, seguros: all(false) }
|
||||
]
|
||||
|
||||
export const SEGUROS_PERMISSION_COLUMNS: {
|
||||
key: SegurosPermissionKey
|
||||
icon: string
|
||||
label: string
|
||||
}[] = [
|
||||
{ key: 'profile', icon: 'i-heroicons-user', label: 'Perfil' },
|
||||
{ key: 'portfolio', icon: 'i-heroicons-folder', label: 'Cartera / pólizas' },
|
||||
{ key: 'layers', icon: 'i-heroicons-squares-2x2', label: 'Capas / duplicados' },
|
||||
{ key: 'tasks', icon: 'i-heroicons-clipboard-document-check', label: 'Tareas / checklist' },
|
||||
{ key: 'billing', icon: 'i-heroicons-currency-dollar', label: 'Cobros / primas' },
|
||||
{ key: 'analytics', icon: 'i-heroicons-chart-pie', label: 'Indicadores' },
|
||||
{ key: 'support', icon: 'i-heroicons-chat-bubble-left-right', label: 'Soporte' }
|
||||
]
|
||||
34
app/data/taxonomy.ts
Normal file
34
app/data/taxonomy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const INSURER_SLUGS = [
|
||||
'acerta',
|
||||
'assa',
|
||||
'ancon',
|
||||
'fedpa',
|
||||
'mapfre',
|
||||
'optima',
|
||||
'palig'
|
||||
] as const
|
||||
|
||||
export type InsurerSlug = (typeof INSURER_SLUGS)[number]
|
||||
|
||||
export const PRODUCT_LINE_SLUGS = [
|
||||
'life',
|
||||
'health_local',
|
||||
'health_international',
|
||||
'auto_full_coverage',
|
||||
'auto_dat_liability',
|
||||
'home',
|
||||
'general_liability',
|
||||
'any'
|
||||
] as const
|
||||
|
||||
export type ProductLineSlug = (typeof PRODUCT_LINE_SLUGS)[number]
|
||||
|
||||
export const INSURER_LABEL: Record<string, string> = {
|
||||
acerta: 'ACERTA',
|
||||
assa: 'ASSA',
|
||||
ancon: 'ANCON',
|
||||
fedpa: 'FEDPA',
|
||||
mapfre: 'MAPFRE',
|
||||
optima: 'OPTIMA',
|
||||
palig: 'PALIG'
|
||||
}
|
||||
@@ -1,69 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { saved: branding, sidebarTitle } = useBrokerageBranding()
|
||||
const { isSuperAdmin } = useSuperAdmin()
|
||||
useAppTheme()
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppShellLayout()
|
||||
const sidebarFeatures = useSidebarFeatures()
|
||||
|
||||
const openGroups = ref({
|
||||
quotes: false,
|
||||
sales: false,
|
||||
cartera: false,
|
||||
customerService: false,
|
||||
workstation: false,
|
||||
aiTools: false
|
||||
})
|
||||
|
||||
// Auto-open the group matching the current route (but never close others)
|
||||
watch(() => route.path, (p) => {
|
||||
if (p.startsWith('/quotes') && p !== '/quotes/new' && p !== '/quotes/compare') openGroups.value.quotes = true
|
||||
if (p.startsWith('/onboarding') || (p.startsWith('/sales') && !p.startsWith('/sales/leads')) || p === '/quotes/new' || p === '/quotes/compare' || p.startsWith('/registration')) openGroups.value.sales = true
|
||||
if (p.startsWith('/customers') || p.startsWith('/policies') || p.startsWith('/cartera')) openGroups.value.cartera = true
|
||||
if (p.startsWith('/support') || p.startsWith('/claims') || p.startsWith('/collections') || p.startsWith('/renewals') || p.startsWith('/sales/leads')) openGroups.value.customerService = true
|
||||
if (p.startsWith('/workstation')) openGroups.value.workstation = true
|
||||
if (p.startsWith('/ai-tools')) openGroups.value.aiTools = true
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
openGroups.value[key] = !openGroups.value[key]
|
||||
}
|
||||
|
||||
function isActive(path: string, exact = false) {
|
||||
const p = route.path
|
||||
return exact ? p === path : p === path || p.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
/* ── Parent link class (40px row, 20px icon, 10px gap) ── */
|
||||
function linkClass(path: string, exact = false) {
|
||||
const active = isActive(path, exact)
|
||||
return [
|
||||
'app-sidebar-link sidebar-parent-link flex w-full items-center',
|
||||
active
|
||||
? 'app-sidebar-link-active'
|
||||
: 'sidebar-link-inactive'
|
||||
]
|
||||
}
|
||||
|
||||
/* ── Child link class (text only, 32px row, 13px, indented) ── */
|
||||
function subLinkClass(path: string, exact = false) {
|
||||
const active = isActive(path, exact)
|
||||
return [
|
||||
'app-sidebar-link sidebar-child-link flex w-full items-center',
|
||||
active
|
||||
? 'app-sidebar-child-active'
|
||||
: 'sidebar-link-inactive'
|
||||
]
|
||||
}
|
||||
|
||||
const isSettingsRoute = computed(() => route.path.startsWith('/settings'))
|
||||
|
||||
function groupBtnClass(key: string) {
|
||||
const hasActive =
|
||||
(key === 'quotes' && ((route.path.startsWith('/quotes') && route.path !== '/quotes/new' && route.path !== '/quotes/compare'))) ||
|
||||
(key === 'sales' && (route.path.startsWith('/onboarding') || (route.path.startsWith('/sales') && !route.path.startsWith('/sales/leads')) || route.path === '/quotes/new' || route.path === '/quotes/compare' || route.path.startsWith('/registration'))) ||
|
||||
(key === 'cartera' &&
|
||||
(route.path.startsWith('/customers') ||
|
||||
route.path.startsWith('/policies') ||
|
||||
route.path.startsWith('/cartera'))) ||
|
||||
(key === 'customerService' &&
|
||||
(route.path.startsWith('/support') ||
|
||||
route.path.startsWith('/claims') ||
|
||||
route.path.startsWith('/collections') ||
|
||||
route.path.startsWith('/renewals') ||
|
||||
route.path.startsWith('/sales/leads'))) ||
|
||||
(key === 'workstation' && route.path.startsWith('/workstation')) ||
|
||||
(key === 'aiTools' && route.path.startsWith('/ai-tools'))
|
||||
return [
|
||||
'app-sidebar-link sidebar-parent-link flex w-full items-center text-left',
|
||||
hasActive
|
||||
? 'sidebar-parent-active'
|
||||
: 'sidebar-link-inactive'
|
||||
]
|
||||
}
|
||||
|
||||
async function onTopRefresh() {
|
||||
try {
|
||||
await refreshNuxtData()
|
||||
} catch {
|
||||
if (import.meta.client) window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd + B to toggle sidebar
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (import.meta.client) window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (import.meta.client) window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100 flex">
|
||||
<div class="flex h-screen flex-col overflow-hidden" style="background: var(--page-bg); color: var(--text-primary);">
|
||||
<LayoutAppTopBar
|
||||
:sidebar-collapsed="sidebarCollapsed"
|
||||
:brand-title="sidebarTitle"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@refresh="onTopRefresh"
|
||||
/>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-64 bg-white border-r border-slate-200 hidden md:flex flex-col"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="h-16 flex items-center px-6 border-b border-slate-200">
|
||||
<span class="text-lg font-semibold tracking-tight">
|
||||
PolicyManager
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="app-sidebar hidden min-h-0 flex-col md:flex md:shrink-0"
|
||||
:class="
|
||||
sidebarCollapsed
|
||||
? 'md:w-0 md:min-w-0 md:max-w-0 md:overflow-hidden md:opacity-0'
|
||||
: 'sidebar-open'
|
||||
"
|
||||
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
|
||||
>
|
||||
<!-- Navigation -->
|
||||
<nav class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden" style="padding: 8px 12px 4px;">
|
||||
<!-- Dashboard — standalone (shrink-0 prevents compression when groups expand) -->
|
||||
<NuxtLink to="/" :class="[...linkClass('/', true), 'shrink-0']">
|
||||
<UIcon name="i-heroicons-squares-2x2" class="sidebar-icon shrink-0" />
|
||||
<span>My Dashboard</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/calendar" :class="[...linkClass('/calendar'), 'shrink-0']">
|
||||
<UIcon name="i-heroicons-calendar-days" class="sidebar-icon shrink-0" />
|
||||
<span>Calendar</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-1">
|
||||
<!-- ── BUSINESS section ── -->
|
||||
<p class="app-sidebar-section-label">Sales</p>
|
||||
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
<div class="flex flex-col">
|
||||
<button type="button" :class="groupBtnClass('quotes')" @click="toggleGroup('quotes')">
|
||||
<UIcon name="i-heroicons-calculator" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Quotes</span>
|
||||
<UIcon
|
||||
:name="openGroups.quotes ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.quotes" class="sidebar-children">
|
||||
<NuxtLink to="/quotes" active-class="" exact-active-class="" :class="subLinkClass('/quotes', true)">Mission Control</NuxtLink>
|
||||
<NuxtLink to="/quotes/auto" active-class="" exact-active-class="" :class="subLinkClass('/quotes/auto')">Auto</NuxtLink>
|
||||
<NuxtLink to="/quotes/health" active-class="" exact-active-class="" :class="subLinkClass('/quotes/health')">Health</NuxtLink>
|
||||
<NuxtLink to="/quotes/life" active-class="" exact-active-class="" :class="subLinkClass('/quotes/life')">Life</NuxtLink>
|
||||
<NuxtLink to="/quotes/general-risk" active-class="" exact-active-class="" :class="subLinkClass('/quotes/general-risk')">General Risk</NuxtLink>
|
||||
<NuxtLink to="/quotes/custom" active-class="" exact-active-class="" :class="subLinkClass('/quotes/custom')">Custom</NuxtLink>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
to="/customers"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Customers
|
||||
</NuxtLink>
|
||||
<button type="button" :class="groupBtnClass('sales')" @click="toggleGroup('sales')">
|
||||
<UIcon name="i-heroicons-funnel" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Sales</span>
|
||||
<UIcon
|
||||
:name="openGroups.sales ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.sales" class="sidebar-children">
|
||||
<NuxtLink to="/onboarding" :class="subLinkClass('/onboarding', true)">Sales Pipeline</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead" :class="subLinkClass('/sales/quick-lead')">Quick Lead</NuxtLink>
|
||||
<NuxtLink to="/registration/client" :class="subLinkClass('/registration/client', true)">New Customer</NuxtLink>
|
||||
<NuxtLink to="/quotes/new" :class="subLinkClass('/quotes/new', true)">Get Quotes</NuxtLink>
|
||||
<NuxtLink to="/quotes/compare" :class="subLinkClass('/quotes/compare', true)">Present Quotes</NuxtLink>
|
||||
<NuxtLink to="/onboarding/solicitud" :class="subLinkClass('/onboarding/solicitud', true)">Solicitudes</NuxtLink>
|
||||
<NuxtLink to="/onboarding/emissions" :class="subLinkClass('/onboarding/emissions')">Emissions</NuxtLink>
|
||||
<NuxtLink to="/onboarding/policy-upload/new" :class="subLinkClass('/onboarding/policy-upload')">Nombramiento</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
to="/policies"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Policies
|
||||
</NuxtLink>
|
||||
<!-- ── OPERATIONS section ── -->
|
||||
<p class="app-sidebar-section-label">Operations</p>
|
||||
|
||||
<NuxtLink
|
||||
to="/providers"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Providers
|
||||
</NuxtLink>
|
||||
<div class="flex flex-col">
|
||||
<button type="button" :class="groupBtnClass('cartera')" @click="toggleGroup('cartera')">
|
||||
<UIcon name="i-heroicons-briefcase" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Cartera</span>
|
||||
<UIcon
|
||||
:name="openGroups.cartera ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.cartera" class="sidebar-children">
|
||||
<NuxtLink to="/customers" :class="subLinkClass('/customers', true)">Customers</NuxtLink>
|
||||
<NuxtLink to="/policies" :class="subLinkClass('/policies', true)">Policies</NuxtLink>
|
||||
<NuxtLink to="/policies/groups" :class="subLinkClass('/policies/groups')">Collectivos</NuxtLink>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
to="/tasks"
|
||||
class="group flex items-center px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition"
|
||||
>
|
||||
Tasks
|
||||
</NuxtLink>
|
||||
<button type="button" :class="groupBtnClass('customerService')" @click="toggleGroup('customerService')">
|
||||
<UIcon name="i-heroicons-lifebuoy" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Customer Service</span>
|
||||
<UIcon
|
||||
:name="openGroups.customerService ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.customerService" class="sidebar-children">
|
||||
<NuxtLink v-if="sidebarFeatures.showLeadsHub" to="/sales/leads" :class="subLinkClass('/sales/leads')">Incoming Leads</NuxtLink>
|
||||
<NuxtLink to="/support" :class="subLinkClass('/support', true)">Incoming Support</NuxtLink>
|
||||
<NuxtLink to="/claims" :class="subLinkClass('/claims')">Claims</NuxtLink>
|
||||
<NuxtLink to="/collections" :class="subLinkClass('/collections')">Collections</NuxtLink>
|
||||
<NuxtLink to="/renewals" :class="subLinkClass('/renewals')">Renewals</NuxtLink>
|
||||
<NuxtLink to="/support/collectivos" :class="subLinkClass('/support/collectivos')">Collectivos</NuxtLink>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</aside>
|
||||
<NuxtLink to="/analysis" :class="linkClass('/analysis')">
|
||||
<UIcon name="i-heroicons-chart-bar-square" class="sidebar-icon shrink-0" />
|
||||
Reports & Analysis
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="flex-1">
|
||||
<div class="p-8">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</main>
|
||||
<!-- ── WORKSTATION section ── -->
|
||||
<template v-if="sidebarFeatures.showWorkstations || sidebarFeatures.showAiTools">
|
||||
<p class="app-sidebar-section-label">Workstations</p>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<template v-if="sidebarFeatures.showWorkstations">
|
||||
<button type="button" :class="groupBtnClass('workstation')" @click="toggleGroup('workstation')">
|
||||
<UIcon name="i-heroicons-inbox-stack" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Workstations</span>
|
||||
<UIcon
|
||||
:name="openGroups.workstation ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.workstation" class="sidebar-children">
|
||||
<NuxtLink to="/workstation/collectivos" :class="subLinkClass('/workstation/collectivos')">Collectivos</NuxtLink>
|
||||
<NuxtLink to="/workstation/collections" :class="subLinkClass('/workstation/collections')">Collections</NuxtLink>
|
||||
<NuxtLink to="/workstation/claims" :class="subLinkClass('/workstation/claims')">Claims</NuxtLink>
|
||||
<NuxtLink to="/workstation/renewals" :class="subLinkClass('/workstation/renewals')">Renewals</NuxtLink>
|
||||
<NuxtLink to="/workstation/customer-service" :class="subLinkClass('/workstation/customer-service')">Customer Service</NuxtLink>
|
||||
<NuxtLink to="/workstation/facturacion" :class="subLinkClass('/workstation/facturacion')">Facturación y Comisiones</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="sidebarFeatures.showAiTools">
|
||||
<button type="button" :class="groupBtnClass('aiTools')" @click="toggleGroup('aiTools')">
|
||||
<UIcon name="i-heroicons-sparkles" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">AI Tools</span>
|
||||
<UIcon
|
||||
:name="openGroups.aiTools ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.aiTools" class="sidebar-children">
|
||||
<NuxtLink to="/ai-tools/sales-factory" :class="subLinkClass('/ai-tools/sales-factory')">Sales Factory</NuxtLink>
|
||||
<NuxtLink to="/ai-tools/policy-comparator" :class="subLinkClass('/ai-tools/policy-comparator')">Policy Comparator</NuxtLink>
|
||||
<NuxtLink to="/ai-tools/email-writer" :class="subLinkClass('/ai-tools/email-writer')">Email Writer</NuxtLink>
|
||||
<NuxtLink to="/ai-tools/case-assistant" :class="subLinkClass('/ai-tools/case-assistant')">Case Assistant</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Footer zone ── -->
|
||||
<div class="mt-auto" style="padding-top: 8px;">
|
||||
<div class="sidebar-footer-divider" />
|
||||
<NuxtLink to="/settings" :class="linkClass('/settings')" class="sidebar-footer-link">
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="sidebar-icon shrink-0" style="width: 16px; height: 16px;" />
|
||||
<span>Settings</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Collapse sidebar — pinned outside scrollable nav -->
|
||||
<div style="padding: 4px 12px 8px;">
|
||||
<button
|
||||
type="button"
|
||||
class="app-sidebar-link sidebar-footer-link flex w-full items-center sidebar-link-inactive"
|
||||
title="Hide sidebar (Ctrl+B)"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<UIcon name="i-heroicons-chevron-double-left" class="sidebar-icon shrink-0" style="width: 16px; height: 16px;" />
|
||||
<span>Hide sidebar</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
:data-app-surface="isSettingsRoute ? 'settings' : undefined"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto" style="padding: 16px 24px 32px;">
|
||||
<NuxtPage :key="route.fullPath" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Sidebar shell ── */
|
||||
.app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
box-shadow: 1px 0 0 0 rgba(0, 0, 0, 0.04);
|
||||
transition: width 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
.sidebar-open {
|
||||
width: 252px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Parent link row: 40px height, 20px icon, 10px gap ── */
|
||||
.sidebar-parent-link {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Parent icon: 20px, outline variant at 0.5 opacity ── */
|
||||
.sidebar-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* Active parent icon: slightly elevated but not full */
|
||||
.app-sidebar-link-active .sidebar-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Chevron: 14px ── */
|
||||
.sidebar-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #c0c0bc;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
/* ── Inactive link: text-secondary, no color change on hover ── */
|
||||
.sidebar-link-inactive {
|
||||
color: #6b6b68;
|
||||
}
|
||||
|
||||
/* ── Parent with active child: text-primary, font-medium ── */
|
||||
.sidebar-parent-active {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Child items: text only, no icons, indented 36px ── */
|
||||
.sidebar-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 38px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Child link: 32px row, 13px font ── */
|
||||
.sidebar-child-link {
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
color: #6b6b68;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Footer divider ── */
|
||||
.sidebar-footer-divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 16px 8px;
|
||||
}
|
||||
|
||||
/* ── Footer links ── */
|
||||
.sidebar-footer-link {
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: #6b6b68 !important;
|
||||
}
|
||||
.sidebar-footer-link .sidebar-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
88
app/pages/account/index.vue
Normal file
88
app/pages/account/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Account')
|
||||
|
||||
const tab = ref('profile')
|
||||
|
||||
const tabItems = [
|
||||
{ label: 'Profile', value: 'profile', icon: 'i-heroicons-user', slot: 'profile' },
|
||||
{ label: 'Theme & appearance', value: 'theme', icon: 'i-heroicons-swatch', slot: 'theme' },
|
||||
{ label: 'Preferences', value: 'prefs', icon: 'i-heroicons-adjustments-horizontal', slot: 'prefs' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl space-y-8">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-1.5 font-medium text-[var(--text-muted)] transition hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<UIcon name="i-heroicons-home" class="h-4 w-4" />
|
||||
Home
|
||||
</NuxtLink>
|
||||
<span class="text-[var(--text-muted)] opacity-50" aria-hidden="true">|</span>
|
||||
<span class="text-[var(--text-muted)]">Signed-in user (mock)</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Your account</h1>
|
||||
<p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Profile, photo, and how the app looks for you. Software-wide configuration stays under Settings; company branding
|
||||
is managed separately by a tenant administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UTabs v-model="tab" :items="tabItems" variant="link" color="primary" class="w-full">
|
||||
<template #profile>
|
||||
<div class="space-y-8 pt-4">
|
||||
<div class="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
<div class="flex shrink-0 flex-col items-center gap-2">
|
||||
<div
|
||||
class="flex h-24 w-24 items-center justify-center rounded-full border border-[var(--sidebar-border)] bg-[var(--brand-faint)] text-[var(--brand)] ring-2 ring-[var(--brand)]/20"
|
||||
>
|
||||
<UIcon name="i-heroicons-user" class="h-14 w-14" />
|
||||
</div>
|
||||
<UButton size="sm" color="neutral" variant="soft" disabled>Change photo (soon)</UButton>
|
||||
<p class="max-w-[12rem] text-center text-[11px] text-[var(--text-muted)]">Avatar syncs when directory login is connected.</p>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-4">
|
||||
<UFormField label="Display name" description="Shown in the top bar when profiles are wired up.">
|
||||
<UInput placeholder="Your name" disabled class="max-w-md" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" description="From your identity provider (not editable here yet).">
|
||||
<UInput model-value="you@company.com" type="email" disabled class="max-w-md" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #theme>
|
||||
<div class="pt-4">
|
||||
<AccountThemeSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #prefs>
|
||||
<div class="space-y-4 pt-4">
|
||||
<p class="text-sm text-[var(--text-muted)]">Locale, density, and defaults will live here when account APIs exist.</p>
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/5">
|
||||
<UFormField label="Language" class="max-w-md">
|
||||
<USelect
|
||||
model-value="en"
|
||||
disabled
|
||||
:items="[
|
||||
{ label: 'English (default)', value: 'en' },
|
||||
{ label: 'Español (soon)', value: 'es' }
|
||||
]"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</template>
|
||||
18
app/pages/ai-tools/case-assistant.vue
Normal file
18
app/pages/ai-tools/case-assistant.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Case Assistant')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||
<div class="inline-flex flex-col items-center gap-4">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||
style="background: rgba(1, 105, 111, 0.06); color: #01696f;"
|
||||
>Coming soon</span>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Case Assistant</h1>
|
||||
<p class="text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Get AI-powered guidance on open cases, including next steps, risk flags, and suggested actions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
18
app/pages/ai-tools/email-writer.vue
Normal file
18
app/pages/ai-tools/email-writer.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Email Writer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||
<div class="inline-flex flex-col items-center gap-4">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||
style="background: rgba(1, 105, 111, 0.06); color: #01696f;"
|
||||
>Coming soon</span>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Email Writer</h1>
|
||||
<p class="text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Draft professional client and carrier emails with AI, using policy context and brokerage tone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
18
app/pages/ai-tools/policy-comparator.vue
Normal file
18
app/pages/ai-tools/policy-comparator.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Policy Comparator')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl py-16 text-center">
|
||||
<div class="inline-flex flex-col items-center gap-4">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||
style="background: rgba(1, 105, 111, 0.06); color: #01696f;"
|
||||
>Coming soon</span>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Policy Comparator</h1>
|
||||
<p class="text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Upload two or more policy documents and get an AI-generated side-by-side coverage comparison.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
158
app/pages/ai-tools/sales-factory.vue
Normal file
158
app/pages/ai-tools/sales-factory.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Sales Factory')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sf-page">
|
||||
<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)]">Sales Factory</h1>
|
||||
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Analyze your book for cross-sell opportunities, assign leads to agents, and connect to email campaigns.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-wider"
|
||||
style="background: rgba(1, 105, 111, 0.06); color: #01696f;"
|
||||
>Coming soon</span>
|
||||
</div>
|
||||
|
||||
<!-- Preview cards -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(1,105,111,0.08); color: #01696f;">
|
||||
<UIcon name="i-heroicons-magnifying-glass-circle" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Book Analysis</h3>
|
||||
<p class="sf-card-desc">Scan your entire book of business for coverage gaps, mono-line clients, and cross-sell opportunities based on client profiles.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">Gap analysis</span>
|
||||
<span class="sf-tag">Mono-line detection</span>
|
||||
<span class="sf-tag">Opportunity scoring</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(124,58,237,0.08); color: #7c3aed;">
|
||||
<UIcon name="i-heroicons-user-plus" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Lead Assignment</h3>
|
||||
<p class="sf-card-desc">Assign qualified leads and opportunities to agents based on capacity, expertise, and territory. Track conversion rates per agent.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">Auto-assignment</span>
|
||||
<span class="sf-tag">Round-robin</span>
|
||||
<span class="sf-tag">Capacity rules</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(194,123,26,0.08); color: #c27b1a;">
|
||||
<UIcon name="i-heroicons-envelope" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Campaign Engine</h3>
|
||||
<p class="sf-card-desc">Connect to email campaigns for renewal nudges, cross-sell outreach, and re-engagement sequences. Track open rates and conversions.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">Email sequences</span>
|
||||
<span class="sf-tag">A/B testing</span>
|
||||
<span class="sf-tag">Analytics</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(15,123,95,0.08); color: #0f7b5f;">
|
||||
<UIcon name="i-heroicons-chart-bar" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Pipeline Intelligence</h3>
|
||||
<p class="sf-card-desc">Visualize your sales pipeline by stage, LOB, and agent. Forecast close rates and identify stalled deals needing attention.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">Pipeline stages</span>
|
||||
<span class="sf-tag">Forecasting</span>
|
||||
<span class="sf-tag">Stall alerts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(193,56,56,0.08); color: #c13838;">
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Cross-sell Engine</h3>
|
||||
<p class="sf-card-desc">AI-powered recommendations for which products to offer existing clients based on their profile, industry, and current coverage.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">AI recommendations</span>
|
||||
<span class="sf-tag">Product matching</span>
|
||||
<span class="sf-tag">Client scoring</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sf-card">
|
||||
<div class="sf-card-icon" style="background: rgba(190,24,93,0.08); color: #be185d;">
|
||||
<UIcon name="i-heroicons-presentation-chart-line" style="width: 20px; height: 20px;" />
|
||||
</div>
|
||||
<h3 class="sf-card-title">Sales Reporting</h3>
|
||||
<p class="sf-card-desc">Agent scorecards, team leaderboards, conversion funnels, and revenue attribution — all in real time.</p>
|
||||
<div class="sf-card-tags">
|
||||
<span class="sf-tag">Scorecards</span>
|
||||
<span class="sf-tag">Leaderboards</span>
|
||||
<span class="sf-tag">Attribution</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sf-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
.sf-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);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
.sf-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
border-color: rgba(1,105,111,0.15);
|
||||
}
|
||||
.sf-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sf-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sf-card-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sf-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.sf-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.03);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
459
app/pages/analysis/index.vue
Normal file
459
app/pages/analysis/index.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ANALYTICS_DOMAIN_LABELS,
|
||||
ANALYTICS_METRICS,
|
||||
type AnalyticsDomainId,
|
||||
type AnalyticsChartType,
|
||||
type AnalyticsTimePoint,
|
||||
} from '~/data/mock-analytics'
|
||||
|
||||
usePageTitle('Business Analytics')
|
||||
|
||||
const {
|
||||
state, allMetrics, kpiSummaries, domainMetrics,
|
||||
chartBuilderMetricObj, chartBuilderData, chartBuilderSvgModel,
|
||||
buildSvgModel, sparklinePath, sparklineArea,
|
||||
} = useAnalytics()
|
||||
|
||||
// ── Domain tabs ──
|
||||
const domains: { id: AnalyticsDomainId; label: string }[] = [
|
||||
{ id: 'production', label: 'Producción' },
|
||||
{ id: 'claims', label: 'Siniestros' },
|
||||
{ id: 'pipeline', label: 'Pipeline' },
|
||||
{ id: 'service', label: 'Servicio' },
|
||||
]
|
||||
|
||||
// ── Per-card chart type overrides ──
|
||||
const cardChartTypes = ref<Record<string, AnalyticsChartType>>({})
|
||||
function getCardChartType(metricId: string, defaultType: AnalyticsChartType): AnalyticsChartType {
|
||||
return cardChartTypes.value[metricId] ?? defaultType
|
||||
}
|
||||
function setCardChartType(metricId: string, type: AnalyticsChartType) {
|
||||
cardChartTypes.value[metricId] = type
|
||||
}
|
||||
|
||||
// ── Chart builder grouped options ──
|
||||
const chartBuilderGroups = computed(() => {
|
||||
const domainOrder: AnalyticsDomainId[] = ['production', 'claims', 'pipeline', 'service']
|
||||
return domainOrder.map(d => ({
|
||||
label: ANALYTICS_DOMAIN_LABELS[d],
|
||||
metrics: allMetrics.filter(m => m.domain === d && m.data12m.some(p => p.m)),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Filter valid data points (skip empties) for chart rendering ──
|
||||
function validData(data: AnalyticsTimePoint[]): AnalyticsTimePoint[] {
|
||||
return data.filter(d => d.m)
|
||||
}
|
||||
|
||||
// ── Change badge class ──
|
||||
function changeToneClass(tone: string): string {
|
||||
if (tone === 'positive') return 'an-change-positive'
|
||||
if (tone === 'negative') return 'an-change-negative'
|
||||
return 'an-change-neutral'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="an-page">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Business Analytics</h1>
|
||||
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Consolidated view — production, claims, pipeline, and service KPIs with interactive charts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ KPI STRIP ═══════════ -->
|
||||
<div class="an-kpi-strip">
|
||||
<div v-for="kpi in kpiSummaries" :key="kpi.id" class="an-kpi-card">
|
||||
<div class="an-kpi-top">
|
||||
<p class="an-kpi-label">{{ kpi.label }}</p>
|
||||
<span :class="['an-change-badge', changeToneClass(kpi.changeTone)]">{{ kpi.change }}</span>
|
||||
</div>
|
||||
<p class="an-kpi-value">{{ kpi.value }}</p>
|
||||
<p class="an-kpi-hint">{{ kpi.hint }}</p>
|
||||
<svg class="an-kpi-spark" viewBox="0 0 112 32" preserveAspectRatio="none">
|
||||
<path :d="sparklineArea(kpi.sparkline)" fill="rgba(1,105,111,0.06)" />
|
||||
<path :d="sparklinePath(kpi.sparkline)" fill="none" stroke="#01696f" stroke-width="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ DOMAIN TABS ═══════════ -->
|
||||
<div class="an-domain-tabs">
|
||||
<button
|
||||
v-for="d in domains"
|
||||
:key="d.id"
|
||||
type="button"
|
||||
class="an-domain-tab"
|
||||
:class="state.activeDomain === d.id ? 'an-tab-active' : 'an-tab-inactive'"
|
||||
@click="state.activeDomain = d.id"
|
||||
>
|
||||
{{ d.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ CHART BUILDER ═══════════ -->
|
||||
<div class="an-builder">
|
||||
<div class="an-builder-header">
|
||||
<p class="an-builder-title">
|
||||
<UIcon name="i-heroicons-wrench-screwdriver" class="w-4 h-4" />
|
||||
Chart Builder
|
||||
</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Pick any metric, chart type, and time range.</p>
|
||||
</div>
|
||||
|
||||
<div class="an-builder-controls">
|
||||
<!-- Grouped metric selector -->
|
||||
<select v-model="state.chartBuilderMetric" class="an-builder-select an-builder-select-wide">
|
||||
<optgroup v-for="group in chartBuilderGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="m in group.metrics" :key="m.id" :value="m.id">{{ m.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<!-- Chart type toggle -->
|
||||
<div class="an-builder-toggle">
|
||||
<button
|
||||
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
|
||||
:key="ct"
|
||||
type="button"
|
||||
class="an-bt-btn"
|
||||
:class="state.chartBuilderType === ct ? 'an-bt-on' : 'an-bt-off'"
|
||||
@click="state.chartBuilderType = ct"
|
||||
>
|
||||
{{ ct.charAt(0).toUpperCase() + ct.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time range -->
|
||||
<div class="an-builder-toggle">
|
||||
<button
|
||||
v-for="r in (['3m', '6m', '12m'] as const)"
|
||||
:key="r"
|
||||
type="button"
|
||||
class="an-bt-btn"
|
||||
:class="state.chartBuilderRange === r ? 'an-bt-on' : 'an-bt-off'"
|
||||
@click="state.chartBuilderRange = r"
|
||||
>
|
||||
{{ r.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder chart info -->
|
||||
<div class="an-builder-info">
|
||||
<p class="text-[18px] font-bold text-[var(--text-primary)]">
|
||||
{{ chartBuilderData[chartBuilderData.length - 1]?.display }}
|
||||
</p>
|
||||
<span :class="['an-change-badge', changeToneClass(chartBuilderMetricObj.changeTone)]">{{ chartBuilderMetricObj.change }}</span>
|
||||
<span class="text-[12px] text-[var(--text-muted)] ml-2">{{ chartBuilderMetricObj.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Builder SVG -->
|
||||
<svg class="an-builder-svg" :viewBox="`0 0 ${chartBuilderSvgModel.viewW} ${chartBuilderSvgModel.viewH}`" preserveAspectRatio="none">
|
||||
<line
|
||||
v-for="(gy, gi) in chartBuilderSvgModel.gridYs"
|
||||
:key="gi"
|
||||
:x1="chartBuilderSvgModel.padX"
|
||||
:y1="gy"
|
||||
:x2="chartBuilderSvgModel.padX + chartBuilderSvgModel.innerW"
|
||||
:y2="gy"
|
||||
stroke="rgba(0,0,0,0.04)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
v-if="state.chartBuilderType === 'area'"
|
||||
:d="chartBuilderSvgModel.areaD"
|
||||
fill="rgba(1,105,111,0.08)"
|
||||
/>
|
||||
<path
|
||||
v-if="state.chartBuilderType !== 'bar'"
|
||||
:d="chartBuilderSvgModel.lineD"
|
||||
fill="none"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
v-if="state.chartBuilderType !== 'bar'"
|
||||
v-for="(pt, pi) in chartBuilderSvgModel.points"
|
||||
:key="pi"
|
||||
:cx="pt.x"
|
||||
:cy="pt.y"
|
||||
r="4"
|
||||
fill="#fff"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<rect
|
||||
v-if="state.chartBuilderType === 'bar'"
|
||||
v-for="(bar, bi) in chartBuilderSvgModel.bars"
|
||||
:key="bi"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.w"
|
||||
:height="bar.h"
|
||||
rx="4"
|
||||
fill="#01696f"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Builder x-axis -->
|
||||
<div class="an-chart-xaxis" style="padding: 0 8px;">
|
||||
<span v-for="(d, di) in chartBuilderData" :key="di" class="an-xaxis-label">{{ d.m }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ CHART CARDS GRID ═══════════ -->
|
||||
<div class="an-chart-grid">
|
||||
<div
|
||||
v-for="metric in domainMetrics"
|
||||
:key="metric.id"
|
||||
class="an-chart-card"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="an-chart-header">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="an-chart-title">{{ metric.label }}</p>
|
||||
<span v-if="metric.change" :class="['an-change-badge', changeToneClass(metric.changeTone)]">{{ metric.change }}</span>
|
||||
</div>
|
||||
<div class="an-chart-type-toggle">
|
||||
<button
|
||||
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
|
||||
:key="ct"
|
||||
type="button"
|
||||
class="an-ct-btn"
|
||||
:class="getCardChartType(metric.id, metric.defaultChartType) === ct ? 'an-ct-on' : 'an-ct-off'"
|
||||
@click="setCardChartType(metric.id, ct)"
|
||||
>
|
||||
{{ ct === 'area' ? '▤' : ct === 'line' ? '⌇' : '▥' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG chart -->
|
||||
<svg class="an-chart-svg" :viewBox="`0 0 ${buildSvgModel(validData(metric.data12m)).viewW} ${buildSvgModel(validData(metric.data12m)).viewH}`" preserveAspectRatio="none">
|
||||
<!-- Grid lines -->
|
||||
<line
|
||||
v-for="(gy, gi) in buildSvgModel(validData(metric.data12m)).gridYs"
|
||||
:key="gi"
|
||||
:x1="buildSvgModel(validData(metric.data12m)).padX"
|
||||
:y1="gy"
|
||||
:x2="buildSvgModel(validData(metric.data12m)).padX + buildSvgModel(validData(metric.data12m)).innerW"
|
||||
:y2="gy"
|
||||
stroke="rgba(0,0,0,0.04)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Area -->
|
||||
<path
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'area'"
|
||||
:d="buildSvgModel(validData(metric.data12m)).areaD"
|
||||
fill="rgba(1,105,111,0.08)"
|
||||
/>
|
||||
<!-- Line -->
|
||||
<path
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
|
||||
:d="buildSvgModel(validData(metric.data12m)).lineD"
|
||||
fill="none"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<!-- Points -->
|
||||
<circle
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
|
||||
v-for="(pt, pi) in buildSvgModel(validData(metric.data12m)).points"
|
||||
:key="pi"
|
||||
:cx="pt.x"
|
||||
:cy="pt.y"
|
||||
r="3"
|
||||
fill="#fff"
|
||||
stroke="#01696f"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Bars -->
|
||||
<rect
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'bar'"
|
||||
v-for="(bar, bi) in buildSvgModel(validData(metric.data12m)).bars"
|
||||
:key="bi"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.w"
|
||||
:height="bar.h"
|
||||
rx="3"
|
||||
fill="#01696f"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<div class="an-chart-xaxis">
|
||||
<span v-for="(d, di) in validData(metric.data12m)" :key="di" class="an-xaxis-label">{{ d.m }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Latest value -->
|
||||
<div class="an-chart-latest">
|
||||
<span class="text-[13px] font-semibold text-[var(--text-primary)]">{{ validData(metric.data12m)[validData(metric.data12m).length - 1]?.display }}</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.an-page {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ══════════ KPI STRIP ══════════ */
|
||||
.an-kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px;
|
||||
}
|
||||
.an-kpi-card {
|
||||
padding: 14px 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);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.an-kpi-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.an-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.an-kpi-value {
|
||||
margin-top: 4px; font-size: 20px; font-weight: 700;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.an-kpi-hint { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.an-kpi-spark {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
width: 100%; height: 32px; opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .an-kpi-strip { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 500px) { .an-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ══════════ CHANGE BADGES ══════════ */
|
||||
.an-change-badge {
|
||||
display: inline-flex; padding: 1px 6px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 700; white-space: nowrap;
|
||||
}
|
||||
.an-change-positive { background: rgba(5,150,105,0.08); color: #059669; }
|
||||
.an-change-negative { background: rgba(193,56,56,0.08); color: #c13838; }
|
||||
.an-change-neutral { background: rgba(0,0,0,0.04); color: #8a8a86; }
|
||||
|
||||
/* ══════════ DOMAIN TABS ══════════ */
|
||||
.an-domain-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
width: fit-content;
|
||||
}
|
||||
.an-domain-tab {
|
||||
padding: 8px 18px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500; border: none;
|
||||
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.an-tab-active { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.an-tab-inactive { background: transparent; color: var(--text-muted); }
|
||||
.an-tab-inactive:hover { color: var(--text-primary); }
|
||||
|
||||
/* ══════════ CHART GRID ══════════ */
|
||||
.an-chart-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;
|
||||
}
|
||||
@media (max-width: 700px) { .an-chart-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.an-chart-card {
|
||||
padding: 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);
|
||||
}
|
||||
|
||||
/* ── Chart header ── */
|
||||
.an-chart-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.an-chart-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
.an-chart-type-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 6px; background: rgba(0,0,0,0.03);
|
||||
}
|
||||
.an-ct-btn {
|
||||
width: 24px; height: 22px; border-radius: 4px;
|
||||
font-size: 10px; border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 100ms ease;
|
||||
}
|
||||
.an-ct-on { background: #01696f; color: #fff; }
|
||||
.an-ct-off { background: transparent; color: #8a8a86; }
|
||||
.an-ct-off:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── SVG chart ── */
|
||||
.an-chart-svg { width: 100%; height: 120px; }
|
||||
|
||||
/* ── X-axis labels ── */
|
||||
.an-chart-xaxis {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 8px 0;
|
||||
}
|
||||
.an-xaxis-label {
|
||||
font-size: 9px; font-weight: 600; color: #8a8a86;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Latest value ── */
|
||||
.an-chart-latest {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px solid rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
/* ══════════ CHART BUILDER ══════════ */
|
||||
.an-builder {
|
||||
padding: 20px; border-radius: 12px;
|
||||
border: 1px solid rgba(1,105,111,0.12); background: rgba(1,105,111,0.01);
|
||||
}
|
||||
.an-builder-header { margin-bottom: 16px; }
|
||||
.an-builder-title {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 15px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
.an-builder-controls {
|
||||
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.an-builder-select {
|
||||
padding: 6px 12px; 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;
|
||||
}
|
||||
.an-builder-select-wide { min-width: 200px; }
|
||||
.an-builder-select:focus { outline: none; border-color: #01696f; }
|
||||
|
||||
.an-builder-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 8px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.an-bt-btn {
|
||||
padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.an-bt-on { background: #01696f; color: #fff; }
|
||||
.an-bt-off { background: transparent; color: #8a8a86; }
|
||||
.an-bt-off:hover { color: var(--text-primary); }
|
||||
|
||||
.an-builder-info {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.an-builder-svg { width: 100%; height: 180px; }
|
||||
</style>
|
||||
1514
app/pages/calendar.vue
Normal file
1514
app/pages/calendar.vue
Normal file
File diff suppressed because it is too large
Load Diff
1198
app/pages/claims/[id].vue
Normal file
1198
app/pages/claims/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
506
app/pages/claims/index.vue
Normal file
506
app/pages/claims/index.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { slaColor, CARRIER_STATUS_LABELS, WORKFLOW_STATUS_LABELS } from '~/data/mock-claims'
|
||||
import type { CarrierStatus, BrokerWorkflowStatus } from '~/data/mock-claims'
|
||||
|
||||
usePageTitle('Claims')
|
||||
|
||||
interface Claim {
|
||||
id: string
|
||||
customer: string
|
||||
agent: string
|
||||
line: string
|
||||
type: string
|
||||
carrier: string
|
||||
reserved: string
|
||||
paid: string
|
||||
daysOpen: number
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'under_review' | 'awaiting_docs' | 'approved' | 'denied' | 'closed'
|
||||
docsPending: number
|
||||
opened: string
|
||||
carrierStatus: CarrierStatus
|
||||
workflowStatus: BrokerWorkflowStatus
|
||||
slaPercent: number
|
||||
handler: string
|
||||
}
|
||||
|
||||
const claims = ref<Claim[]>([
|
||||
{ id: 'CLM-0048', customer: 'Hotel Pacífico', agent: 'Marco V.', line: 'General Risk', type: 'Fire damage', carrier: 'ASSA', reserved: '$128,000', paid: '$0', daysOpen: 3, priority: 'critical', status: 'open', docsPending: 4, opened: 'Apr 2, 2026', carrierStatus: 'investigation', workflowStatus: 'waiting_carrier', slaPercent: 110, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0047', customer: 'Empresa ABC S.A.', agent: 'Ana R.', line: 'Auto', type: 'Collision — fleet unit #7', carrier: 'Qualitas', reserved: '$14,200', paid: '$0', daysOpen: 5, priority: 'high', status: 'under_review', docsPending: 2, opened: 'Mar 31, 2026', carrierStatus: 'documentation_pending', workflowStatus: 'waiting_insured_docs', slaPercent: 60, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0046', customer: 'Jorge Herrera', agent: 'Marco V.', line: 'Auto', type: 'Windshield replacement', carrier: 'Qualitas', reserved: '$1,100', paid: '$0', daysOpen: 8, priority: 'low', status: 'awaiting_docs', docsPending: 1, opened: 'Mar 28, 2026', carrierStatus: 'documentation_pending', workflowStatus: 'waiting_insured_docs', slaPercent: 40, handler: 'Marco V.' },
|
||||
{ id: 'CLM-0045', customer: 'Clínica San José', agent: 'Ana R.', line: 'Life', type: 'Surgery pre-auth', carrier: 'Pan-American Life', reserved: '$23,500', paid: '$0', daysOpen: 12, priority: 'high', status: 'under_review', docsPending: 0, opened: 'Mar 24, 2026', carrierStatus: 'reserved', workflowStatus: 'waiting_carrier', slaPercent: 85, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0044', customer: 'Carmen Ruiz', agent: 'Ana R.', line: 'Life', type: 'Outpatient claim', carrier: 'Pan-American Life', reserved: '$3,800', paid: '$3,200', daysOpen: 18, priority: 'medium', status: 'approved', docsPending: 0, opened: 'Mar 18, 2026', carrierStatus: 'settlement_offered', workflowStatus: 'ready_to_close', slaPercent: 50, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0043', customer: 'Supermercado Tico', agent: 'Marco V.', line: 'General Risk', type: 'Customer injury — store premises', carrier: 'Mapfre', reserved: '$45,000', paid: '$0', daysOpen: 22, priority: 'high', status: 'under_review', docsPending: 3, opened: 'Mar 14, 2026', carrierStatus: 'negotiation', workflowStatus: 'client_update_overdue', slaPercent: 100, handler: 'Marco V.' },
|
||||
{ id: 'CLM-0042', customer: 'Isabel Mora', agent: 'Ana R.', line: 'Auto', type: 'Theft — total loss', carrier: 'ASSA', reserved: '$18,500', paid: '$18,500', daysOpen: 35, priority: 'medium', status: 'closed', docsPending: 0, opened: 'Mar 1, 2026', carrierStatus: 'closed', workflowStatus: 'ready_to_close', slaPercent: 95, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0041', customer: 'Manuel Torres', agent: 'Marco V.', line: 'Life', type: 'Disability benefit', carrier: 'Pan-American Life', reserved: '$52,000', paid: '$12,000', daysOpen: 41, priority: 'medium', status: 'approved', docsPending: 0, opened: 'Feb 23, 2026', carrierStatus: 'reserved', workflowStatus: 'waiting_carrier', slaPercent: 70, handler: 'Marco V.' },
|
||||
])
|
||||
|
||||
// ── View toggle ─────────────────────────────────────────────────────────────
|
||||
const viewMode = ref<'my' | 'all'>('all')
|
||||
|
||||
type ClaimFilter = 'all' | 'active' | 'resolved'
|
||||
const activeFilter = ref<ClaimFilter>('all')
|
||||
|
||||
// ── Filter dropdowns ────────────────────────────────────────────────────────
|
||||
const statusFilter = ref('')
|
||||
const carrierFilter = ref('')
|
||||
const lobFilter = ref('')
|
||||
const handlerFilter = ref('')
|
||||
const agingFilter = ref('')
|
||||
const priorityFilter = ref('')
|
||||
|
||||
const uniqueCarriers = computed(() => [...new Set(claims.value.map(c => c.carrier))].sort())
|
||||
const uniqueLobs = computed(() => [...new Set(claims.value.map(c => c.line))].sort())
|
||||
const uniqueHandlers = computed(() => [...new Set(claims.value.map(c => c.handler))].sort())
|
||||
|
||||
const filteredClaims = computed(() => {
|
||||
let result = [...claims.value]
|
||||
|
||||
if (activeFilter.value === 'active') result = result.filter(c => !['closed', 'denied'].includes(c.status))
|
||||
if (activeFilter.value === 'resolved') result = result.filter(c => ['closed', 'denied'].includes(c.status))
|
||||
|
||||
if (statusFilter.value) result = result.filter(c => c.status === statusFilter.value)
|
||||
if (carrierFilter.value) result = result.filter(c => c.carrier === carrierFilter.value)
|
||||
if (lobFilter.value) result = result.filter(c => c.line === lobFilter.value)
|
||||
if (handlerFilter.value) result = result.filter(c => c.handler === handlerFilter.value)
|
||||
if (priorityFilter.value) result = result.filter(c => c.priority === priorityFilter.value)
|
||||
|
||||
if (agingFilter.value) {
|
||||
const ranges: Record<string, [number, number]> = { '0-7': [0, 7], '8-14': [8, 14], '15-30': [15, 30], '30+': [30, 9999] }
|
||||
const [min, max] = ranges[agingFilter.value] ?? [0, 9999]
|
||||
result = result.filter(c => c.daysOpen >= min && c.daysOpen <= max)
|
||||
}
|
||||
|
||||
// Sort: breached first
|
||||
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 b.slaPercent - a.slaPercent
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const filterCounts = computed(() => ({
|
||||
all: claims.value.length,
|
||||
active: claims.value.filter(c => !['closed', 'denied'].includes(c.status)).length,
|
||||
resolved: claims.value.filter(c => ['closed', 'denied'].includes(c.status)).length,
|
||||
}))
|
||||
|
||||
const kpis = computed(() => {
|
||||
const active = claims.value.filter(c => !['closed', 'denied'].includes(c.status))
|
||||
const underReview = claims.value.filter(c => c.status === 'under_review').length
|
||||
const avgDays = active.length ? Math.round(active.reduce((s, c) => s + c.daysOpen, 0) / active.length) : 0
|
||||
const totalReserved = claims.value.filter(c => c.status !== 'closed').reduce((s, c) => s + parseFloat(c.reserved.replace(/[$,]/g, '')), 0)
|
||||
const breached = claims.value.filter(c => c.slaPercent >= 100 && !['closed', 'denied'].includes(c.status)).length
|
||||
return { openClaims: active.length, underReview, avgDays, totalReserved, breached }
|
||||
})
|
||||
|
||||
const statusMeta: Record<string, { label: string; class: string }> = {
|
||||
open: { label: 'Open', class: 'cl-st-open' },
|
||||
under_review: { label: 'Under Review', class: 'cl-st-review' },
|
||||
awaiting_docs: { label: 'Awaiting Docs', class: 'cl-st-docs' },
|
||||
approved: { label: 'Approved', class: 'cl-st-approved' },
|
||||
denied: { label: 'Denied', class: 'cl-st-denied' },
|
||||
closed: { label: 'Closed', class: 'cl-st-closed' },
|
||||
}
|
||||
|
||||
const priorityMeta: Record<string, { label: string; class: string }> = {
|
||||
critical: { label: 'Critical', class: 'cl-pri-critical' },
|
||||
high: { label: 'High', class: 'cl-pri-high' },
|
||||
medium: { label: 'Med', class: 'cl-pri-medium' },
|
||||
low: { label: 'Low', class: 'cl-pri-low' },
|
||||
}
|
||||
|
||||
const carrierPillClass = (s: CarrierStatus) => {
|
||||
const map: Record<string, string> = {
|
||||
fnol_submitted: 'cl-csp-fnol', acknowledged: 'cl-csp-ack', investigation: 'cl-csp-inv',
|
||||
documentation_pending: 'cl-csp-doc', reserved: 'cl-csp-rsv', negotiation: 'cl-csp-neg',
|
||||
settlement_offered: 'cl-csp-set', closed: 'cl-csp-closed',
|
||||
}
|
||||
return map[s] ?? ''
|
||||
}
|
||||
|
||||
function formatCurrency(n: number) {
|
||||
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
statusFilter.value = ''
|
||||
carrierFilter.value = ''
|
||||
lobFilter.value = ''
|
||||
handlerFilter.value = ''
|
||||
agingFilter.value = ''
|
||||
priorityFilter.value = ''
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => !!(statusFilter.value || carrierFilter.value || lobFilter.value || handlerFilter.value || agingFilter.value || priorityFilter.value))
|
||||
|
||||
const toast = useToast()
|
||||
function handleNewClaim() {
|
||||
toast.add({ title: 'New claim flow coming soon', description: 'This will open the FNOL intake wizard.', color: 'neutral' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cl-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)]">Claims</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Track claims lifecycle from first notice of loss through resolution and payment.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="cl-action-btn-primary" @click="handleNewClaim">
|
||||
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
||||
New Claim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div class="cl-kpi-strip">
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Open claims</p>
|
||||
<p class="cl-kpi-value">{{ kpis.openClaims }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Under review</p>
|
||||
<p class="cl-kpi-value" style="color: #c27b1a;">{{ kpis.underReview }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">SLA breached</p>
|
||||
<p class="cl-kpi-value" :style="kpis.breached > 0 ? 'color: #c13838;' : ''">{{ kpis.breached }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Avg days open</p>
|
||||
<p class="cl-kpi-value">{{ kpis.avgDays }}d</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Total reserved</p>
|
||||
<p class="cl-kpi-value">{{ formatCurrency(kpis.totalReserved) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View toggle + Filter tabs -->
|
||||
<div class="cl-controls-row">
|
||||
<div class="cl-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="cl-view-btn"
|
||||
:class="viewMode === 'my' ? 'cl-view-on' : 'cl-view-off'"
|
||||
@click="viewMode = 'my'"
|
||||
>My Claims</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cl-view-btn"
|
||||
:class="viewMode === 'all' ? 'cl-view-on' : 'cl-view-off'"
|
||||
@click="viewMode = 'all'"
|
||||
>All Claims</button>
|
||||
</div>
|
||||
|
||||
<div class="cl-filter-tabs">
|
||||
<button
|
||||
v-for="f in ([
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'resolved', label: 'Resolved' },
|
||||
] as { id: ClaimFilter; label: string }[])"
|
||||
:key="f.id"
|
||||
type="button"
|
||||
class="cl-filter-tab"
|
||||
:class="activeFilter === f.id ? 'cl-filter-on' : 'cl-filter-off'"
|
||||
@click="activeFilter = f.id"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span class="cl-filter-count" :class="activeFilter === f.id ? 'cl-filter-count-on' : ''">{{ filterCounts[f.id] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[11px] text-[var(--text-muted)] ml-auto">{{ filteredClaims.length }} results</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter dropdowns row -->
|
||||
<div class="cl-dropdown-row">
|
||||
<select v-model="statusFilter" class="cl-dropdown">
|
||||
<option value="">Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="under_review">Under Review</option>
|
||||
<option value="awaiting_docs">Awaiting Docs</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="denied">Denied</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<select v-model="carrierFilter" class="cl-dropdown">
|
||||
<option value="">Carrier</option>
|
||||
<option v-for="c in uniqueCarriers" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="lobFilter" class="cl-dropdown">
|
||||
<option value="">LOB</option>
|
||||
<option v-for="l in uniqueLobs" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
<select v-model="handlerFilter" class="cl-dropdown">
|
||||
<option value="">Handler</option>
|
||||
<option v-for="h in uniqueHandlers" :key="h" :value="h">{{ h }}</option>
|
||||
</select>
|
||||
<select v-model="agingFilter" class="cl-dropdown">
|
||||
<option value="">Aging</option>
|
||||
<option value="0-7">0–7 days</option>
|
||||
<option value="8-14">8–14 days</option>
|
||||
<option value="15-30">15–30 days</option>
|
||||
<option value="30+">30+ days</option>
|
||||
</select>
|
||||
<select v-model="priorityFilter" class="cl-dropdown">
|
||||
<option value="">Priority</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<button v-if="hasActiveFilters" class="cl-clear-btn" @click="clearFilters">
|
||||
<UIcon name="i-heroicons-x-mark" class="w-3 h-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Claims table -->
|
||||
<div class="cl-table-wrap">
|
||||
<table class="cl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28px;"></th>
|
||||
<th>Claim</th>
|
||||
<th>Customer / Agent</th>
|
||||
<th>Line / Type</th>
|
||||
<th>Carrier</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Reserved</th>
|
||||
<th class="text-right">Paid</th>
|
||||
<th class="text-right">Days</th>
|
||||
<th>Priority</th>
|
||||
<th class="text-center">Docs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in filteredClaims"
|
||||
:key="c.id"
|
||||
class="cl-row"
|
||||
:class="{ 'cl-breach-row': c.slaPercent >= 100 && !['closed', 'denied'].includes(c.status) }"
|
||||
style="cursor: pointer;"
|
||||
@click="navigateTo(`/claims/${c.id}`)"
|
||||
>
|
||||
<td><span class="cl-sla-dot" :class="`cl-sla-${slaColor(c.slaPercent)}`" /></td>
|
||||
<td>
|
||||
<NuxtLink :to="`/claims/${c.id}`" class="cl-claim-link" @click.stop>{{ c.id }}</NuxtLink>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">{{ c.customer || 'Unnamed customer' }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ c.agent || '—' }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-[13px] text-[var(--text-primary)]">{{ c.line }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ c.type }}</p>
|
||||
</td>
|
||||
<td class="text-[13px] text-[var(--text-muted)]">{{ c.carrier }}</td>
|
||||
<td>
|
||||
<div class="cl-dual-status">
|
||||
<span class="cl-carrier-status-pill" :class="carrierPillClass(c.carrierStatus)">{{ CARRIER_STATUS_LABELS[c.carrierStatus] }}</span>
|
||||
<span class="cl-workflow-status-pill">{{ WORKFLOW_STATUS_LABELS[c.workflowStatus] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right font-semibold text-[13px] text-[var(--text-primary)]">{{ c.reserved }}</td>
|
||||
<td class="text-right text-[13px]" :class="c.paid !== '$0' ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)] opacity-50'">{{ c.paid }}</td>
|
||||
<td class="text-right">
|
||||
<span class="text-[13px] font-bold" :class="c.daysOpen > 30 ? 'text-rose-600' : c.daysOpen > 14 ? 'text-amber-600' : 'text-[var(--text-primary)]'">{{ c.daysOpen }}d</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="priorityMeta[c.priority].class">{{ priorityMeta[c.priority].label }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span v-if="c.docsPending > 0" class="cl-docs-badge">{{ c.docsPending }}</span>
|
||||
<span v-else class="text-[11px] text-[var(--text-muted)] opacity-40">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl-page {
|
||||
max-width: 76rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.cl-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;
|
||||
}
|
||||
.cl-action-btn-primary:hover { background: #015458; }
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.cl-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;
|
||||
}
|
||||
.cl-kpi { padding: 14px 18px; background: #fff; }
|
||||
.cl-kpi:first-child { border-radius: 12px 0 0 12px; }
|
||||
.cl-kpi:last-child { border-radius: 0 12px 12px 0; }
|
||||
.cl-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.cl-kpi-value {
|
||||
margin-top: 4px; font-size: 22px; font-weight: 600;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 640px) { .cl-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ── Controls row ── */
|
||||
.cl-controls-row {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── View toggle ── */
|
||||
.cl-view-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 8px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.cl-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;
|
||||
}
|
||||
.cl-view-on { background: #01696f; color: white; }
|
||||
.cl-view-off { background: transparent; color: #8a8a86; }
|
||||
.cl-view-off:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── Filter tabs ── */
|
||||
.cl-filter-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.cl-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;
|
||||
}
|
||||
.cl-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.cl-filter-off { background: transparent; color: var(--text-muted); }
|
||||
.cl-filter-off:hover { color: var(--text-primary); }
|
||||
.cl-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);
|
||||
}
|
||||
.cl-filter-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
|
||||
/* ── Dropdown filters ── */
|
||||
.cl-dropdown-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.cl-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;
|
||||
}
|
||||
.cl-dropdown:focus { outline: none; border-color: #01696f; }
|
||||
.cl-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;
|
||||
}
|
||||
.cl-clear-btn:hover { background: rgba(193, 56, 56, 0.12); }
|
||||
|
||||
/* ── Table ── */
|
||||
.cl-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;
|
||||
}
|
||||
.cl-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cl-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;
|
||||
}
|
||||
.cl-table tbody td {
|
||||
padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: top;
|
||||
}
|
||||
.cl-row { transition: background 100ms ease; }
|
||||
.cl-row:hover { background: rgba(0,0,0,0.015); }
|
||||
.cl-row:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── Breach row ── */
|
||||
.cl-breach-row { box-shadow: inset 3px 0 0 #c13838; }
|
||||
|
||||
/* ── SLA dot ── */
|
||||
.cl-sla-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.cl-sla-green { background: #059669; }
|
||||
.cl-sla-amber { background: #c27b1a; }
|
||||
.cl-sla-red { background: #c13838; }
|
||||
|
||||
/* ── Claim link ── */
|
||||
.cl-claim-link {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px; font-weight: 600; color: #01696f;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cl-claim-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Dual status pills ── */
|
||||
.cl-dual-status { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cl-carrier-status-pill {
|
||||
display: inline-flex; padding: 2px 7px; border-radius: 8px;
|
||||
font-size: 10px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.cl-csp-fnol { background: rgba(59, 130, 246, 0.08); color: #2563eb; }
|
||||
.cl-csp-ack { background: rgba(16, 185, 129, 0.08); color: #059669; }
|
||||
.cl-csp-inv { background: rgba(245, 158, 11, 0.08); color: #d97706; }
|
||||
.cl-csp-doc { background: rgba(147, 51, 234, 0.08); color: #9333ea; }
|
||||
.cl-csp-rsv { background: rgba(1, 105, 111, 0.08); color: #01696f; }
|
||||
.cl-csp-neg { background: rgba(194, 123, 26, 0.08); color: #c27b1a; }
|
||||
.cl-csp-set { background: rgba(16, 185, 129, 0.08); color: #059669; }
|
||||
.cl-csp-closed { background: rgba(138, 138, 134, 0.08); color: #8a8a86; }
|
||||
|
||||
.cl-workflow-status-pill {
|
||||
display: inline-flex; padding: 0; border-radius: 0;
|
||||
font-size: 10px; font-weight: 500; white-space: nowrap;
|
||||
border: none; color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Status badges ── */
|
||||
.cl-st-open { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(193,56,56,0.08); color: #c13838; white-space: nowrap; }
|
||||
.cl-st-review { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap; }
|
||||
.cl-st-docs { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(147,51,234,0.08); color: #9333ea; white-space: nowrap; }
|
||||
.cl-st-approved { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap; }
|
||||
.cl-st-denied { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: #6b6b68; white-space: nowrap; }
|
||||
.cl-st-closed { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap; }
|
||||
|
||||
/* ── Priority badges ── */
|
||||
.cl-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; }
|
||||
.cl-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; }
|
||||
.cl-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; }
|
||||
.cl-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; }
|
||||
|
||||
/* ── Docs pending badge ── */
|
||||
.cl-docs-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 20px; padding: 0 5px;
|
||||
border-radius: 9999px; background: rgba(147,51,234,0.08); color: #9333ea;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
672
app/pages/claims/intake/[token].vue
Normal file
672
app/pages/claims/intake/[token].vue
Normal file
@@ -0,0 +1,672 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false, layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token as string
|
||||
|
||||
// ── Mock claim lookup by token ────────────────────────────────────────────────
|
||||
interface IntakeClaim {
|
||||
id: string
|
||||
customerName: string
|
||||
policyNumber: string
|
||||
carrier: string
|
||||
lob: 'Auto' | 'Life' | 'General Risk' | 'Home'
|
||||
handler: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
const MOCK_TOKENS: Record<string, IntakeClaim> = {
|
||||
'tk_hp_048_a3f1': { id: 'CLM-0048', customerName: 'Hotel Pacífico S.A.', policyNumber: 'PROP-2024-HP-001', carrier: 'ASSA', lob: 'General Risk', handler: 'Ana R.', expiresAt: '2026-04-09T14:30:00Z' },
|
||||
'tk_abc_047_b7e2': { id: 'CLM-0047', customerName: 'Empresa ABC S.A.', policyNumber: 'AUTO-2024-FLEET-007', carrier: 'Qualitas', lob: 'Auto', handler: 'Ana R.', expiresAt: '2026-04-07T11:00:00Z' },
|
||||
'tk_st_043_c9d4': { id: 'CLM-0043', customerName: 'Supermercado Tico S.A.', policyNumber: 'GL-2023-ST-001', carrier: 'Mapfre', lob: 'General Risk', handler: 'Marco V.', expiresAt: '2026-03-20T16:00:00Z' },
|
||||
'demo-auto': { id: 'CLM-DEMO-A', customerName: 'Demo Auto Client', policyNumber: 'AUTO-DEMO-001', carrier: 'ASSA', lob: 'Auto', handler: 'Ana R.', expiresAt: '2026-12-31T23:59:00Z' },
|
||||
'demo-life': { id: 'CLM-DEMO-L', customerName: 'Demo Life Client', policyNumber: 'LIFE-DEMO-001', carrier: 'Pan-American Life', lob: 'Life', handler: 'Ana R.', expiresAt: '2026-12-31T23:59:00Z' },
|
||||
}
|
||||
|
||||
const claim = computed(() => MOCK_TOKENS[token] ?? null)
|
||||
const expired = computed(() => {
|
||||
if (!claim.value) return false
|
||||
return new Date(claim.value.expiresAt) < new Date()
|
||||
})
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
const currentStep = ref(0)
|
||||
const submitted = ref(false)
|
||||
|
||||
const steps = computed(() => {
|
||||
const base = [
|
||||
{ id: 'incident', label: 'Incident Details' },
|
||||
{ id: 'parties', label: claim.value?.lob === 'Auto' ? 'Vehicles & Parties' : claim.value?.lob === 'Life' ? 'Patient & Provider' : 'Property & Parties' },
|
||||
{ id: 'documents', label: 'Documents & Photos' },
|
||||
{ id: 'review', label: 'Review & Submit' },
|
||||
]
|
||||
return base
|
||||
})
|
||||
|
||||
function nextStep() { if (currentStep.value < steps.value.length - 1) currentStep.value++ }
|
||||
function prevStep() { if (currentStep.value > 0) currentStep.value-- }
|
||||
function submitForm() { submitted.value = true }
|
||||
|
||||
// ── Form data ─────────────────────────────────────────────────────────────────
|
||||
const form = reactive({
|
||||
// Step 1: Incident
|
||||
incidentDate: '',
|
||||
incidentTime: '',
|
||||
incidentLocation: '',
|
||||
incidentDescription: '',
|
||||
|
||||
// Step 2: Auto-specific
|
||||
vehicleMake: '',
|
||||
vehicleModel: '',
|
||||
vehicleYear: '',
|
||||
vehiclePlate: '',
|
||||
vehicleColor: '',
|
||||
otherDriverName: '',
|
||||
otherDriverPhone: '',
|
||||
otherDriverInsurance: '',
|
||||
otherDriverPlate: '',
|
||||
witnessName: '',
|
||||
witnessPhone: '',
|
||||
|
||||
// Step 2: Life-specific
|
||||
patientName: '',
|
||||
patientDob: '',
|
||||
patientCedula: '',
|
||||
providerName: '',
|
||||
providerAddress: '',
|
||||
diagnosis: '',
|
||||
treatmentDates: '',
|
||||
|
||||
// Step 2: Property/General Risk
|
||||
propertyAddress: '',
|
||||
propertyType: '',
|
||||
damageDescription: '',
|
||||
emergencyServicesCalled: false,
|
||||
thirdPartyInvolved: false,
|
||||
|
||||
// Step 3: Documents
|
||||
photoDescriptions: [] as string[],
|
||||
hasSignedFud: false,
|
||||
additionalNotes: '',
|
||||
})
|
||||
|
||||
// ── Photo uploads (mock) ──────────────────────────────────────────────────────
|
||||
const photoSlots = computed(() => {
|
||||
if (!claim.value) return []
|
||||
if (claim.value.lob === 'Auto') return [
|
||||
{ id: 'front', label: 'Front of vehicle' },
|
||||
{ id: 'rear', label: 'Rear of vehicle' },
|
||||
{ id: 'left', label: 'Left side' },
|
||||
{ id: 'right', label: 'Right side' },
|
||||
{ id: 'damage', label: 'Close-up of damage' },
|
||||
{ id: 'fud', label: 'FUD firmado — foto del documento (si aplica)', optional: true },
|
||||
]
|
||||
if (claim.value.lob === 'Life') return [
|
||||
{ id: 'prescription', label: 'Medical prescription' },
|
||||
{ id: 'referral', label: 'Specialist referral' },
|
||||
{ id: 'records', label: 'Medical records' },
|
||||
]
|
||||
return [
|
||||
{ id: 'damage1', label: 'Damage photo 1' },
|
||||
{ id: 'damage2', label: 'Damage photo 2' },
|
||||
{ id: 'damage3', label: 'Additional photos' },
|
||||
{ id: 'report', label: 'Fire/police report (if available)', optional: true },
|
||||
]
|
||||
})
|
||||
|
||||
const uploadedPhotos = ref<Record<string, boolean>>({})
|
||||
function mockUpload(slotId: string) { uploadedPhotos.value[slotId] = true }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ci-page">
|
||||
<!-- Segur-OS branding bar -->
|
||||
<div class="ci-brand-bar">
|
||||
<span class="ci-brand-logo">Segur-OS</span>
|
||||
<span class="ci-brand-tag">Client Intake Form</span>
|
||||
</div>
|
||||
|
||||
<!-- Invalid / expired token -->
|
||||
<template v-if="!claim">
|
||||
<div class="ci-error">
|
||||
<div class="ci-error-icon">!</div>
|
||||
<h2>Invalid Link</h2>
|
||||
<p>This intake form link is invalid or has expired. Please contact your broker for a new link.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="expired">
|
||||
<div class="ci-error">
|
||||
<div class="ci-error-icon">⏱</div>
|
||||
<h2>Link Expired</h2>
|
||||
<p>This intake form link expired on {{ new Date(claim.expiresAt).toLocaleDateString() }}. Please contact your broker to request a new link.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="submitted">
|
||||
<div class="ci-success">
|
||||
<div class="ci-success-icon">✓</div>
|
||||
<h2>Thank You</h2>
|
||||
<p>Your claim information for <strong>{{ claim.id }}</strong> has been submitted successfully.</p>
|
||||
<p class="ci-success-sub">Your broker {{ claim.handler }} will review the information and follow up with you shortly.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Main form -->
|
||||
<template v-else>
|
||||
<!-- Claim context header -->
|
||||
<div class="ci-context">
|
||||
<div class="ci-context-left">
|
||||
<h1 class="ci-context-title">{{ claim.customerName }}</h1>
|
||||
<p class="ci-context-meta">{{ claim.id }} · {{ claim.policyNumber }} · {{ claim.carrier }}</p>
|
||||
</div>
|
||||
<div class="ci-context-right">
|
||||
<span class="ci-context-lob">{{ claim.lob }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="ci-steps">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="step.id"
|
||||
class="ci-step"
|
||||
:class="{
|
||||
'ci-step-done': idx < currentStep,
|
||||
'ci-step-active': idx === currentStep,
|
||||
'ci-step-pending': idx > currentStep,
|
||||
}"
|
||||
>
|
||||
<div class="ci-step-circle">
|
||||
<span v-if="idx < currentStep">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="ci-step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="ci-card">
|
||||
<!-- ═══ Step 1: Incident Details ═══ -->
|
||||
<template v-if="currentStep === 0">
|
||||
<h2 class="ci-section-title">Incident Details</h2>
|
||||
<p class="ci-section-desc">When and where did the incident occur?</p>
|
||||
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Date of Incident</label>
|
||||
<input v-model="form.incidentDate" type="date" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Time (approximate)</label>
|
||||
<input v-model="form.incidentTime" type="time" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Location</label>
|
||||
<input v-model="form.incidentLocation" type="text" class="ci-input" placeholder="Street address, intersection, or description" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Description of what happened</label>
|
||||
<textarea v-model="form.incidentDescription" class="ci-textarea" rows="4" placeholder="Describe the incident in detail..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: Auto ═══ -->
|
||||
<template v-if="currentStep === 1 && claim.lob === 'Auto'">
|
||||
<h2 class="ci-section-title">Vehicles & Parties</h2>
|
||||
<p class="ci-section-desc">Your vehicle and other parties involved.</p>
|
||||
|
||||
<h3 class="ci-subsection">Your Vehicle</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Make</label>
|
||||
<input v-model="form.vehicleMake" type="text" class="ci-input" placeholder="Toyota" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Model</label>
|
||||
<input v-model="form.vehicleModel" type="text" class="ci-input" placeholder="Hilux" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Year</label>
|
||||
<input v-model="form.vehicleYear" type="text" class="ci-input" placeholder="2024" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Plate</label>
|
||||
<input v-model="form.vehiclePlate" type="text" class="ci-input" placeholder="ABC-123" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Color</label>
|
||||
<input v-model="form.vehicleColor" type="text" class="ci-input" placeholder="White" />
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Other Driver</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Name</label>
|
||||
<input v-model="form.otherDriverName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Phone</label>
|
||||
<input v-model="form.otherDriverPhone" type="tel" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Insurance Company</label>
|
||||
<input v-model="form.otherDriverInsurance" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Plate Number</label>
|
||||
<input v-model="form.otherDriverPlate" type="text" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Witness (if any)</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Name</label>
|
||||
<input v-model="form.witnessName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Phone</label>
|
||||
<input v-model="form.witnessPhone" type="tel" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: Life ═══ -->
|
||||
<template v-if="currentStep === 1 && claim.lob === 'Life'">
|
||||
<h2 class="ci-section-title">Patient & Provider</h2>
|
||||
<p class="ci-section-desc">Information about the patient and medical provider.</p>
|
||||
|
||||
<h3 class="ci-subsection">Patient Information</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Patient Name</label>
|
||||
<input v-model="form.patientName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Date of Birth</label>
|
||||
<input v-model="form.patientDob" type="date" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Cédula / ID</label>
|
||||
<input v-model="form.patientCedula" type="text" class="ci-input" />
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Medical Provider</h3>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Provider / Hospital Name</label>
|
||||
<input v-model="form.providerName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Provider Address</label>
|
||||
<input v-model="form.providerAddress" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Diagnosis</label>
|
||||
<textarea v-model="form.diagnosis" class="ci-textarea" rows="3" placeholder="Describe the diagnosis or reason for treatment..." />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Treatment Dates</label>
|
||||
<input v-model="form.treatmentDates" type="text" class="ci-input" placeholder="e.g. April 1–5, 2026" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: General Risk / Home ═══ -->
|
||||
<template v-if="currentStep === 1 && (claim.lob === 'General Risk' || claim.lob === 'Home')">
|
||||
<h2 class="ci-section-title">Property & Parties</h2>
|
||||
<p class="ci-section-desc">Details about the affected property.</p>
|
||||
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Property Address</label>
|
||||
<input v-model="form.propertyAddress" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Property Type</label>
|
||||
<select v-model="form.propertyType" class="ci-input">
|
||||
<option value="">Select...</option>
|
||||
<option value="commercial">Commercial</option>
|
||||
<option value="residential">Residential</option>
|
||||
<option value="industrial">Industrial</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Damage Description</label>
|
||||
<textarea v-model="form.damageDescription" class="ci-textarea" rows="4" placeholder="Describe the damage in detail..." />
|
||||
</div>
|
||||
<div class="ci-field-row">
|
||||
<label class="ci-checkbox">
|
||||
<input v-model="form.emergencyServicesCalled" type="checkbox" />
|
||||
<span>Emergency services were called (fire, police, ambulance)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ci-field-row">
|
||||
<label class="ci-checkbox">
|
||||
<input v-model="form.thirdPartyInvolved" type="checkbox" />
|
||||
<span>Third parties are involved</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 3: Documents & Photos ═══ -->
|
||||
<template v-if="currentStep === 2">
|
||||
<h2 class="ci-section-title">Documents & Photos</h2>
|
||||
<p class="ci-section-desc">Upload photos and supporting documents. Take clear, well-lit photos.</p>
|
||||
|
||||
<div class="ci-photo-grid">
|
||||
<div v-for="slot in photoSlots" :key="slot.id" class="ci-photo-slot">
|
||||
<div class="ci-photo-box" :class="{ 'ci-photo-uploaded': uploadedPhotos[slot.id] }" @click="mockUpload(slot.id)">
|
||||
<template v-if="uploadedPhotos[slot.id]">
|
||||
<span class="ci-photo-check">✓</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ci-photo-plus">+</span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="ci-photo-label">{{ slot.label }}</span>
|
||||
<span v-if="slot.optional" class="ci-photo-optional">Optional</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ci-field" style="margin-top: 20px;">
|
||||
<label class="ci-label">Additional Notes</label>
|
||||
<textarea v-model="form.additionalNotes" class="ci-textarea" rows="3" placeholder="Anything else your broker should know..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 4: Review & Submit ═══ -->
|
||||
<template v-if="currentStep === 3">
|
||||
<h2 class="ci-section-title">Review & Submit</h2>
|
||||
<p class="ci-section-desc">Please review your information before submitting.</p>
|
||||
|
||||
<div class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Incident</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Date</span>
|
||||
<span class="ci-review-value">{{ form.incidentDate || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Time</span>
|
||||
<span class="ci-review-value">{{ form.incidentTime || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Location</span>
|
||||
<span class="ci-review-value">{{ form.incidentLocation || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Description</span>
|
||||
<span class="ci-review-value">{{ form.incidentDescription || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'Auto'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Vehicle</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Vehicle</span>
|
||||
<span class="ci-review-value">{{ form.vehicleYear }} {{ form.vehicleMake }} {{ form.vehicleModel }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Plate</span>
|
||||
<span class="ci-review-value">{{ form.vehiclePlate || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Other Driver</span>
|
||||
<span class="ci-review-value">{{ form.otherDriverName || 'Not provided' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'Life'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Patient & Provider</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Patient</span>
|
||||
<span class="ci-review-value">{{ form.patientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Provider</span>
|
||||
<span class="ci-review-value">{{ form.providerName || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Diagnosis</span>
|
||||
<span class="ci-review-value">{{ form.diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'General Risk' || claim.lob === 'Home'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Property</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Address</span>
|
||||
<span class="ci-review-value">{{ form.propertyAddress || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Type</span>
|
||||
<span class="ci-review-value">{{ form.propertyType || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Damage</span>
|
||||
<span class="ci-review-value">{{ form.damageDescription || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Documents</h3>
|
||||
<p class="ci-review-photos">{{ Object.values(uploadedPhotos).filter(Boolean).length }} of {{ photoSlots.length }} photos uploaded</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class="ci-nav">
|
||||
<button v-if="currentStep > 0" class="ci-btn-back" @click="prevStep">
|
||||
← Back
|
||||
</button>
|
||||
<div class="ci-nav-spacer" />
|
||||
<button v-if="currentStep < steps.length - 1" class="ci-btn-next" @click="nextStep">
|
||||
Continue →
|
||||
</button>
|
||||
<button v-if="currentStep === steps.length - 1" class="ci-btn-submit" @click="submitForm">
|
||||
Submit Claim Information
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="ci-footer">
|
||||
<p>Powered by <strong>Segur-OS</strong> · This form does not require a login</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
CLIENT INTAKE FORM — mobile-first, no layout, ci- prefix
|
||||
===================================================================== */
|
||||
|
||||
.ci-page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 48px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
background: #f8f8f6;
|
||||
}
|
||||
|
||||
/* ── Brand bar ── */
|
||||
.ci-brand-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 16px 0; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.ci-brand-logo { font-size: 16px; font-weight: 800; color: #01696f; letter-spacing: -0.02em; }
|
||||
.ci-brand-tag { font-size: 12px; color: #8a8a86; font-weight: 500; }
|
||||
|
||||
/* ── Error / expired ── */
|
||||
.ci-error { text-align: center; padding: 60px 16px; }
|
||||
.ci-error-icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.ci-error h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
||||
.ci-error p { font-size: 14px; color: #5c5650; line-height: 1.6; }
|
||||
|
||||
/* ── Success ── */
|
||||
.ci-success { text-align: center; padding: 60px 16px; }
|
||||
.ci-success-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(1, 105, 111, 0.1); color: #01696f;
|
||||
font-size: 28px; font-weight: 700; margin-bottom: 16px;
|
||||
}
|
||||
.ci-success h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
||||
.ci-success p { font-size: 14px; color: #3a3a3a; line-height: 1.6; }
|
||||
.ci-success-sub { color: #8a8a86; margin-top: 8px; }
|
||||
|
||||
/* ── Context header ── */
|
||||
.ci-context {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 12px 16px;
|
||||
background: white; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.ci-context-title { font-size: 16px; font-weight: 700; }
|
||||
.ci-context-meta { font-size: 12px; color: #8a8a86; margin-top: 2px; }
|
||||
.ci-context-lob {
|
||||
display: inline-flex; padding: 4px 10px;
|
||||
border-radius: 8px; font-size: 11px; font-weight: 700;
|
||||
background: rgba(1, 105, 111, 0.08); color: #01696f;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Step indicator ── */
|
||||
.ci-steps {
|
||||
display: flex; gap: 4px; margin-bottom: 20px; overflow-x: auto;
|
||||
}
|
||||
.ci-step {
|
||||
display: flex; align-items: center; gap: 6px; padding: 8px 12px;
|
||||
border-radius: 8px; font-size: 12px; font-weight: 500;
|
||||
white-space: nowrap; flex: 1; min-width: 0;
|
||||
}
|
||||
.ci-step-circle {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.ci-step-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-step-done { color: #01696f; }
|
||||
.ci-step-done .ci-step-circle { background: #01696f; color: white; }
|
||||
.ci-step-active { color: #1a1a1a; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.ci-step-active .ci-step-circle { background: #01696f; color: white; }
|
||||
.ci-step-pending { color: #8a8a86; }
|
||||
.ci-step-pending .ci-step-circle { background: rgba(0,0,0,0.06); color: #8a8a86; }
|
||||
|
||||
/* ── Card ── */
|
||||
.ci-card {
|
||||
background: white; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; padding: 20px 16px;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.ci-section-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
||||
.ci-section-desc { font-size: 13px; color: #8a8a86; margin-bottom: 20px; }
|
||||
.ci-subsection { font-size: 14px; font-weight: 600; margin: 20px 0 10px; padding-top: 16px; border-top: 1px solid rgba(0,0,0,0.06); }
|
||||
|
||||
/* ── Fields ── */
|
||||
.ci-field { margin-bottom: 14px; }
|
||||
.ci-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||
.ci-field-row { margin-bottom: 12px; }
|
||||
.ci-label { display: block; font-size: 12px; font-weight: 600; color: #5c5650; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.ci-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px; font-size: 14px; color: #1a1a1a;
|
||||
background: white; transition: border-color 150ms ease;
|
||||
}
|
||||
.ci-input:focus { outline: none; border-color: #01696f; }
|
||||
.ci-textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px; font-size: 14px; color: #1a1a1a;
|
||||
resize: vertical; font-family: inherit;
|
||||
}
|
||||
.ci-textarea:focus { outline: none; border-color: #01696f; }
|
||||
.ci-checkbox {
|
||||
display: flex; align-items: flex-start; gap: 8px; cursor: pointer;
|
||||
font-size: 14px; color: #3a3a3a;
|
||||
}
|
||||
.ci-checkbox input { margin-top: 3px; accent-color: #01696f; }
|
||||
|
||||
/* ── Photo grid ── */
|
||||
.ci-photo-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px;
|
||||
}
|
||||
.ci-photo-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.ci-photo-box {
|
||||
width: 100%; aspect-ratio: 4/3; border: 2px dashed rgba(0,0,0,0.12);
|
||||
border-radius: 10px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 150ms ease; background: rgba(0,0,0,0.02);
|
||||
}
|
||||
.ci-photo-box:hover { border-color: #01696f; background: rgba(1, 105, 111, 0.03); }
|
||||
.ci-photo-uploaded { border-style: solid; border-color: #01696f; background: rgba(1, 105, 111, 0.06); }
|
||||
.ci-photo-plus { font-size: 24px; color: #8a8a86; }
|
||||
.ci-photo-check { font-size: 24px; color: #01696f; font-weight: 700; }
|
||||
.ci-photo-label { font-size: 11px; color: #5c5650; text-align: center; line-height: 1.3; }
|
||||
.ci-photo-optional { font-size: 10px; color: #8a8a86; font-style: italic; }
|
||||
|
||||
/* ── Review ── */
|
||||
.ci-review-section { margin-bottom: 20px; }
|
||||
.ci-review-heading { font-size: 14px; font-weight: 700; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||
.ci-review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.ci-review-full { grid-column: 1 / -1; }
|
||||
.ci-review-item { }
|
||||
.ci-review-label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 2px; }
|
||||
.ci-review-value { font-size: 14px; color: #1a1a1a; }
|
||||
.ci-review-photos { font-size: 13px; color: #5c5650; }
|
||||
|
||||
/* ── Navigation ── */
|
||||
.ci-nav {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-top: 16px; padding: 0 4px;
|
||||
}
|
||||
.ci-nav-spacer { flex: 1; }
|
||||
.ci-btn-back {
|
||||
padding: 10px 20px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
background: white; color: #5c5650; border: 1px solid rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ci-btn-back:hover { color: #1a1a1a; border-color: rgba(0,0,0,0.2); }
|
||||
.ci-btn-next {
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.ci-btn-next:hover { opacity: 0.9; }
|
||||
.ci-btn-submit {
|
||||
padding: 12px 28px; border-radius: 10px; font-size: 14px; font-weight: 700;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.ci-btn-submit:hover { opacity: 0.9; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.ci-footer {
|
||||
text-align: center; padding: 24px 0; margin-top: 32px;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 12px; color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 480px) {
|
||||
.ci-field-grid { grid-template-columns: 1fr; }
|
||||
.ci-photo-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.ci-review-grid { grid-template-columns: 1fr; }
|
||||
.ci-steps { gap: 2px; }
|
||||
.ci-step-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
416
app/pages/claims/settings.vue
Normal file
416
app/pages/claims/settings.vue
Normal file
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Claims Settings')
|
||||
|
||||
// ── SLA Rules ─────────────────────────────────────────────────────────────────
|
||||
interface SlaRule {
|
||||
lob: string
|
||||
targetDays: number
|
||||
tier1Pct: number
|
||||
tier2Pct: number
|
||||
tier3Pct: number
|
||||
}
|
||||
|
||||
const slaRules = ref<SlaRule[]>([
|
||||
{ lob: 'Auto', targetDays: 14, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Life', targetDays: 21, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'General Risk', targetDays: 30, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Home', targetDays: 21, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Fianza', targetDays: 10, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
])
|
||||
|
||||
function tierDays(rule: SlaRule, pct: number) {
|
||||
return Math.round(rule.targetDays * pct / 100)
|
||||
}
|
||||
|
||||
// ── Escalation Tiers ──────────────────────────────────────────────────────────
|
||||
interface EscalationTier {
|
||||
threshold: string
|
||||
action: string
|
||||
notify: string
|
||||
}
|
||||
|
||||
const escalationTiers = ref<EscalationTier[]>([
|
||||
{ threshold: '50% of SLA', action: 'Notify handler', notify: 'Handler' },
|
||||
{ threshold: '75% of SLA', action: 'Notify handler + manager', notify: 'Handler, Manager' },
|
||||
{ threshold: '100% of SLA', action: 'Auto-escalate to manager', notify: 'Manager, Team Lead' },
|
||||
])
|
||||
|
||||
// ── Required Document Gates ───────────────────────────────────────────────────
|
||||
interface DocGate {
|
||||
status: string
|
||||
docTypes: string[]
|
||||
}
|
||||
|
||||
const docGateStatuses = ['FNOL Submitted', 'Investigation', 'Documentation Pending', 'Reserved', 'Negotiation', 'Settlement']
|
||||
const docTypes = ['FNOL Form', 'Police Report', 'Photos', 'Estimates', 'Medical Records', 'Proof of Loss', 'Settlement Letter']
|
||||
|
||||
const docGates = ref<Record<string, Record<string, boolean>>>({})
|
||||
|
||||
// Initialize doc gates
|
||||
for (const status of docGateStatuses) {
|
||||
docGates.value[status] = {}
|
||||
for (const doc of docTypes) {
|
||||
// Defaults: FNOL always required, Photos after investigation
|
||||
if (doc === 'FNOL Form') docGates.value[status][doc] = true
|
||||
else if (doc === 'Photos' && ['Investigation', 'Documentation Pending', 'Reserved', 'Negotiation', 'Settlement'].includes(status)) docGates.value[status][doc] = true
|
||||
else if (doc === 'Estimates' && ['Reserved', 'Negotiation', 'Settlement'].includes(status)) docGates.value[status][doc] = true
|
||||
else if (doc === 'Settlement Letter' && status === 'Settlement') docGates.value[status][doc] = true
|
||||
else docGates.value[status][doc] = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDocGate(status: string, doc: string) {
|
||||
docGates.value[status][doc] = !docGates.value[status][doc]
|
||||
}
|
||||
|
||||
// ── Alert Thresholds ──────────────────────────────────────────────────────────
|
||||
const alertThresholds = reactive({
|
||||
reserveIncreasePct: 25,
|
||||
ageDays: 30,
|
||||
carrierNonResponseDays: 5,
|
||||
documentOverdueDays: 7,
|
||||
})
|
||||
|
||||
// ── Form Templates ────────────────────────────────────────────────────────────
|
||||
interface FormTemplate {
|
||||
id: string
|
||||
name: string
|
||||
carrier: string
|
||||
lob: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const formTemplates = ref<FormTemplate[]>([
|
||||
{ id: 'ft-1', name: 'Informe de Accidente', carrier: 'ASSA', lob: 'Auto', active: true },
|
||||
{ id: 'ft-2', name: 'Informe de Accidente', carrier: 'Qualitas', lob: 'Auto', active: true },
|
||||
{ id: 'ft-3', name: 'Aviso de Pérdida', carrier: 'ASSA', lob: 'General Risk', active: true },
|
||||
{ id: 'ft-4', name: 'Aviso de Pérdida', carrier: 'Mapfre', lob: 'General Risk', active: true },
|
||||
{ id: 'ft-5', name: 'Reclamos Médicos', carrier: 'Pan-American Life', lob: 'Life', active: true },
|
||||
{ id: 'ft-6', name: 'Reclamos Médicos', carrier: 'ASSA', lob: 'Life', active: false },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs-page">
|
||||
<!-- Header -->
|
||||
<div class="cs-header">
|
||||
<div>
|
||||
<NuxtLink to="/claims" class="cs-back-link">
|
||||
<UIcon name="i-heroicons-arrow-left" class="w-3.5 h-3.5" />
|
||||
Back to Claims
|
||||
</NuxtLink>
|
||||
<h1 class="cs-title">Claims Settings</h1>
|
||||
<p class="cs-subtitle">Configure SLA rules, escalation tiers, required documents, and alert thresholds.</p>
|
||||
</div>
|
||||
<button class="cs-save-btn">
|
||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 1: SLA Rule Builder ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">SLA Rule Builder</h2>
|
||||
<p class="cs-card-desc">Set target resolution days per line of business. Escalation tiers auto-compute from percentages.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap">
|
||||
<table class="cs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line of Business</th>
|
||||
<th>Target Days</th>
|
||||
<th>Tier 1 ({{ slaRules[0]?.tier1Pct ?? 50 }}%)</th>
|
||||
<th>Tier 2 ({{ slaRules[0]?.tier2Pct ?? 75 }}%)</th>
|
||||
<th>Tier 3 ({{ slaRules[0]?.tier3Pct ?? 100 }}%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in slaRules" :key="rule.lob">
|
||||
<td class="cs-td-bold">{{ rule.lob }}</td>
|
||||
<td>
|
||||
<input v-model.number="rule.targetDays" type="number" min="1" max="365" class="cs-input-sm" />
|
||||
</td>
|
||||
<td class="cs-td-computed">{{ tierDays(rule, rule.tier1Pct) }} days</td>
|
||||
<td class="cs-td-computed">{{ tierDays(rule, rule.tier2Pct) }} days</td>
|
||||
<td class="cs-td-computed cs-td-red">{{ tierDays(rule, rule.tier3Pct) }} days</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 2: Escalation Tiers ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-bell-alert" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Escalation Tiers</h2>
|
||||
<p class="cs-card-desc">Actions triggered when SLA thresholds are reached.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-escalation-list">
|
||||
<div v-for="(tier, idx) in escalationTiers" :key="idx" class="cs-escalation-row">
|
||||
<div class="cs-escalation-dot" :class="idx === 0 ? 'cs-dot-green' : idx === 1 ? 'cs-dot-amber' : 'cs-dot-red'" />
|
||||
<div class="cs-escalation-content">
|
||||
<span class="cs-escalation-threshold">{{ tier.threshold }}</span>
|
||||
<div class="cs-escalation-fields">
|
||||
<div class="cs-field-inline">
|
||||
<label class="cs-label-sm">Action</label>
|
||||
<input v-model="tier.action" type="text" class="cs-input-med" />
|
||||
</div>
|
||||
<div class="cs-field-inline">
|
||||
<label class="cs-label-sm">Notify</label>
|
||||
<input v-model="tier.notify" type="text" class="cs-input-med" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 3: Required Document Gates ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-folder-open" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Required Document Gates</h2>
|
||||
<p class="cs-card-desc">Check which documents are required at each carrier status stage. Missing docs generate tasks automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap cs-matrix-wrap">
|
||||
<table class="cs-table cs-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status ↓ / Doc →</th>
|
||||
<th v-for="doc in docTypes" :key="doc" class="cs-th-rotated">
|
||||
<span>{{ doc }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="status in docGateStatuses" :key="status">
|
||||
<td class="cs-td-bold">{{ status }}</td>
|
||||
<td v-for="doc in docTypes" :key="doc" class="cs-td-check" @click="toggleDocGate(status, doc)">
|
||||
<span v-if="docGates[status][doc]" class="cs-check-on">✓</span>
|
||||
<span v-else class="cs-check-off">·</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 4: Alert Thresholds ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Alert Thresholds</h2>
|
||||
<p class="cs-card-desc">Configure when the system flags claims for attention.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-alert-grid">
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Reserve Increase Trigger</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.reserveIncreasePct" type="number" min="1" max="100" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">% increase</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Alert when reserve changes by more than this percentage.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Claim Age Warning</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.ageDays" type="number" min="1" max="365" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Highlight claims older than this threshold.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Carrier Non-Response</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.carrierNonResponseDays" type="number" min="1" max="30" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Suggest escalation when carrier hasn't responded.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Document Overdue</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.documentOverdueDays" type="number" min="1" max="30" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Flag overdue required documents after this many days.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 5: Carrier Form Templates ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-document-duplicate" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Carrier Form Templates</h2>
|
||||
<p class="cs-card-desc">Manage which carrier-specific forms are available for generation. Government forms (FUD) are not managed here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap">
|
||||
<table class="cs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Form Name</th>
|
||||
<th>Carrier</th>
|
||||
<th>LOB</th>
|
||||
<th>Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ft in formTemplates" :key="ft.id">
|
||||
<td class="cs-td-bold">{{ ft.name }}</td>
|
||||
<td>{{ ft.carrier }}</td>
|
||||
<td>{{ ft.lob }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="cs-toggle"
|
||||
:class="ft.active ? 'cs-toggle-on' : 'cs-toggle-off'"
|
||||
@click="ft.active = !ft.active"
|
||||
>
|
||||
<span class="cs-toggle-dot" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
CLAIMS SETTINGS — scoped, cs- prefix
|
||||
===================================================================== */
|
||||
|
||||
.cs-page {
|
||||
max-width: 64rem; margin: 0 auto;
|
||||
display: flex; flex-direction: column; gap: 24px; padding-bottom: 48px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.cs-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.cs-back-link {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; font-weight: 500; color: #8a8a86;
|
||||
text-decoration: none; margin-bottom: 8px; transition: color 150ms ease;
|
||||
}
|
||||
.cs-back-link:hover { color: #01696f; }
|
||||
.cs-title { font-size: 22px; font-weight: 700; color: #1a1a1a; }
|
||||
.cs-subtitle { font-size: 13px; color: #8a8a86; margin-top: 4px; }
|
||||
.cs-save-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px;
|
||||
border-radius: 10px; font-size: 13px; font-weight: 600;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.cs-save-btn:hover { opacity: 0.9; }
|
||||
|
||||
/* ── Card ── */
|
||||
.cs-card {
|
||||
background: #fff; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
.cs-card-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; color: #01696f; }
|
||||
.cs-card-title { font-size: 16px; font-weight: 700; color: #1a1a1a; }
|
||||
.cs-card-desc { font-size: 12px; color: #8a8a86; margin-top: 2px; }
|
||||
|
||||
/* ── Table ── */
|
||||
.cs-table-wrap { overflow-x: auto; }
|
||||
.cs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cs-table thead th {
|
||||
padding: 8px 12px; font-size: 10px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: #8a8a86; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
text-align: left; white-space: nowrap;
|
||||
}
|
||||
.cs-table tbody td {
|
||||
padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cs-table tbody tr:last-child td { border-bottom: none; }
|
||||
.cs-td-bold { font-weight: 600; }
|
||||
.cs-td-computed { color: #5c5650; font-variant-numeric: tabular-nums; }
|
||||
.cs-td-red { color: #c13838; font-weight: 600; }
|
||||
|
||||
/* ── Inputs ── */
|
||||
.cs-input-sm {
|
||||
width: 72px; padding: 5px 8px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px; font-size: 13px; text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cs-input-sm:focus { outline: none; border-color: #01696f; }
|
||||
.cs-input-med {
|
||||
flex: 1; padding: 5px 10px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px; font-size: 13px;
|
||||
}
|
||||
.cs-input-med:focus { outline: none; border-color: #01696f; }
|
||||
.cs-input-group { display: flex; align-items: center; gap: 6px; }
|
||||
.cs-input-suffix { font-size: 12px; color: #8a8a86; }
|
||||
.cs-label { display: block; font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; }
|
||||
.cs-label-sm { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: #8a8a86; margin-bottom: 2px; }
|
||||
|
||||
/* ── Escalation ── */
|
||||
.cs-escalation-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cs-escalation-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.cs-escalation-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
|
||||
.cs-dot-green { background: #059669; }
|
||||
.cs-dot-amber { background: #c27b1a; }
|
||||
.cs-dot-red { background: #c13838; }
|
||||
.cs-escalation-content { flex: 1; }
|
||||
.cs-escalation-threshold { font-size: 14px; font-weight: 700; display: block; margin-bottom: 8px; }
|
||||
.cs-escalation-fields { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.cs-field-inline { display: flex; flex-direction: column; flex: 1; min-width: 180px; }
|
||||
|
||||
/* ── Document Matrix ── */
|
||||
.cs-matrix-wrap { max-height: 500px; }
|
||||
.cs-matrix th, .cs-matrix td { text-align: center; }
|
||||
.cs-matrix td:first-child, .cs-matrix th:first-child { text-align: left; }
|
||||
.cs-th-rotated span {
|
||||
writing-mode: vertical-lr; transform: rotate(180deg);
|
||||
font-size: 10px; white-space: nowrap;
|
||||
}
|
||||
.cs-td-check { cursor: pointer; padding: 6px 8px !important; }
|
||||
.cs-td-check:hover { background: rgba(1, 105, 111, 0.04); }
|
||||
.cs-check-on { color: #01696f; font-weight: 700; font-size: 16px; }
|
||||
.cs-check-off { color: rgba(0,0,0,0.15); font-size: 20px; }
|
||||
|
||||
/* ── Alert Grid ── */
|
||||
.cs-alert-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.cs-alert-item { }
|
||||
.cs-alert-help { font-size: 11px; color: #8a8a86; margin-top: 4px; }
|
||||
|
||||
/* ── Toggle ── */
|
||||
.cs-toggle {
|
||||
width: 36px; height: 20px; border-radius: 10px; border: none;
|
||||
cursor: pointer; position: relative; transition: background 200ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
.cs-toggle-on { background: #01696f; }
|
||||
.cs-toggle-off { background: rgba(0,0,0,0.15); }
|
||||
.cs-toggle-dot {
|
||||
display: block; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: white; position: absolute; top: 2px;
|
||||
transition: left 200ms ease;
|
||||
}
|
||||
.cs-toggle-on .cs-toggle-dot { left: 18px; }
|
||||
.cs-toggle-off .cs-toggle-dot { left: 2px; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.cs-alert-grid { grid-template-columns: 1fr; }
|
||||
.cs-escalation-fields { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
2724
app/pages/collections/index.vue
Normal file
2724
app/pages/collections/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
||||
const { isFavorite, toggleFavorite } = useClientFavorites()
|
||||
|
||||
const page = ref(1)
|
||||
const search = ref('')
|
||||
const customerTypeFilter = ref<string | null>(null)
|
||||
const agentFilter = ref<string | null>(null)
|
||||
const paymentFilter = ref<string | null>(null)
|
||||
const sortBy = ref<string>('name_asc')
|
||||
const viewMode = ref<'card' | 'list'>('card')
|
||||
const debouncedSearch = refDebounced(search, 300)
|
||||
|
||||
const customerTypeItems = [
|
||||
@@ -12,7 +18,26 @@ const customerTypeItems = [
|
||||
{ label: 'Corporate', value: 'corporate' }
|
||||
]
|
||||
|
||||
watch([debouncedSearch, customerTypeFilter], () => { page.value = 1 })
|
||||
const agentItems = [
|
||||
{ label: 'All Agents', value: null },
|
||||
...([...new Set(MOCK_CUSTOMERS.map(c => c.agent))].sort().map(a => ({ label: a, value: a })))
|
||||
]
|
||||
|
||||
const paymentItems = [
|
||||
{ label: 'All Payments', value: null },
|
||||
{ label: 'Current', value: 'Current' },
|
||||
{ label: 'Overdue', value: 'Overdue' },
|
||||
{ label: 'Grace period', value: 'Grace period' },
|
||||
{ label: 'N/A', value: 'N/A' },
|
||||
]
|
||||
|
||||
const sortItems = [
|
||||
{ label: 'Name (A–Z)', value: 'name_asc' },
|
||||
{ label: 'Premium (high–low)', value: 'premium_desc' },
|
||||
{ label: 'Policies (most)', value: 'policies_desc' },
|
||||
]
|
||||
|
||||
watch([debouncedSearch, customerTypeFilter, agentFilter, paymentFilter], () => { page.value = 1 })
|
||||
|
||||
const { data, pending, refresh } = useCustomer('/customers', {
|
||||
query: computed(() => {
|
||||
@@ -34,80 +59,151 @@ const { data, pending, refresh } = useCustomer('/customers', {
|
||||
}
|
||||
|
||||
return {
|
||||
page_size: 20,
|
||||
page: page.value,
|
||||
'page[number]': page.value,
|
||||
'page[size]': 20,
|
||||
...filters
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const customers = computed(() => data.value?.data ?? [])
|
||||
/* ── Mock fallback rows (shown when API returns nothing) ── */
|
||||
const allMockRows = MOCK_CUSTOMERS.map((m) => ({
|
||||
id: m.id,
|
||||
customer_type: m.type.toLowerCase(),
|
||||
first_name: m.name.split(' ')[0],
|
||||
last_name: m.name.split(' ').slice(1).join(' '),
|
||||
commercial_name: null,
|
||||
legal_name: null,
|
||||
ruc: null,
|
||||
email: m.email,
|
||||
phone: m.phone,
|
||||
birth_date: m.birthDate,
|
||||
gender: m.gender.toLowerCase(),
|
||||
legal_rep_name: null,
|
||||
document_id: m.documentId,
|
||||
_mock: m
|
||||
}))
|
||||
|
||||
const mockRows = computed(() => {
|
||||
let rows = allMockRows
|
||||
// Filter by search
|
||||
const q = debouncedSearch.value.toLowerCase().trim()
|
||||
if (q) {
|
||||
rows = rows.filter(r => {
|
||||
const name = `${r.first_name} ${r.last_name}`.toLowerCase()
|
||||
return name.includes(q)
|
||||
|| (r.email ?? '').toLowerCase().includes(q)
|
||||
|| (r.phone ?? '').includes(q)
|
||||
|| (r.document_id ?? '').toLowerCase().includes(q)
|
||||
|| (r._mock.agent ?? '').toLowerCase().includes(q)
|
||||
|| r._mock.tags.some(t => t.toLowerCase().includes(q))
|
||||
})
|
||||
}
|
||||
// Filter by type
|
||||
if (customerTypeFilter.value) {
|
||||
rows = rows.filter(r => r.customer_type === customerTypeFilter.value)
|
||||
}
|
||||
// Filter by agent
|
||||
if (agentFilter.value) {
|
||||
rows = rows.filter(r => r._mock.agent === agentFilter.value)
|
||||
}
|
||||
// Filter by payment status
|
||||
if (paymentFilter.value) {
|
||||
rows = rows.filter(r => r._mock.paymentStatus === paymentFilter.value)
|
||||
}
|
||||
// Sort
|
||||
const sorted = [...rows]
|
||||
switch (sortBy.value) {
|
||||
case 'name_asc':
|
||||
sorted.sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`))
|
||||
break
|
||||
case 'premium_desc':
|
||||
sorted.sort((a, b) => b._mock.policies.reduce((s: number, p: any) => s + p.premium, 0) - a._mock.policies.reduce((s: number, p: any) => s + p.premium, 0))
|
||||
break
|
||||
case 'policies_desc':
|
||||
sorted.sort((a, b) => b._mock.policies.length - a._mock.policies.length)
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
|
||||
const apiCustomers = computed(() => data.value?.data ?? [])
|
||||
const customers = computed(() => apiCustomers.value.length > 0 ? apiCustomers.value : mockRows.value)
|
||||
const meta = computed(() => data.value?.meta)
|
||||
|
||||
// display helpers
|
||||
const customerName = (c: any) =>
|
||||
c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
const usingMock = computed(() => apiCustomers.value.length === 0 && mockRows.value.length > 0)
|
||||
|
||||
const customerSubtitle = (c: any) =>
|
||||
c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
// display helpers
|
||||
const customerName = (c: any) => {
|
||||
if (c.customer_type === 'corporate') return c.commercial_name || c.legal_name || 'Unnamed company'
|
||||
const full = [c.first_name, c.last_name].filter(Boolean).join(' ')
|
||||
return full || 'Unnamed customer'
|
||||
}
|
||||
|
||||
const customerSubtitle = (c: any) => {
|
||||
if (c._mock) {
|
||||
const m = c._mock
|
||||
const total = m.policies.reduce((s: number, p: any) => s + p.premium, 0)
|
||||
return `${m.policies.length} ${m.policies.length === 1 ? 'policy' : 'policies'} · ${fmtMoney(total)}/yr`
|
||||
}
|
||||
return c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
}
|
||||
|
||||
const customerTypeColor = (type: string) =>
|
||||
type === 'corporate' ? 'purple' : 'blue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="ci-root mx-auto max-w-7xl pb-12">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">Customers</h1>
|
||||
<p class="text-gray-500 text-sm">Customer Relationship Management</p>
|
||||
<div class="ci-header">
|
||||
<div class="ci-header-left">
|
||||
<h1 class="ci-title">Customers</h1>
|
||||
<span class="ci-count-badge">{{ usingMock ? customers.length : (meta?.total_count ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">
|
||||
{{ meta?.total_count ?? 0 }} customers
|
||||
</UBadge>
|
||||
<div class="ci-header-right">
|
||||
<NuxtLink to="/customers/new">
|
||||
<UButton icon="i-heroicons-plus" color="primary">New Customer</UButton>
|
||||
<button class="ci-btn-primary">
|
||||
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
|
||||
New Customer
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<!-- Filter bar -->
|
||||
<div class="ci-filter-bar">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-80"
|
||||
placeholder="Search by name, email, RUC, agent..."
|
||||
class="ci-filter-search"
|
||||
/>
|
||||
<USelect
|
||||
v-model="customerTypeFilter"
|
||||
:items="customerTypeItems"
|
||||
class="w-44"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
<USelect v-model="customerTypeFilter" :items="customerTypeItems" class="ci-filter-select" />
|
||||
<USelect v-model="agentFilter" :items="agentItems" class="ci-filter-select" />
|
||||
<USelect v-model="paymentFilter" :items="paymentItems" class="ci-filter-select" />
|
||||
<USelect v-model="sortBy" :items="sortItems" class="ci-filter-select" />
|
||||
<UButton icon="i-heroicons-arrow-path" color="neutral" variant="ghost" size="sm" :loading="pending" @click="refresh()">Refresh</UButton>
|
||||
<div class="ci-view-toggle">
|
||||
<button type="button" :class="['ci-view-toggle-btn', viewMode === 'card' && 'ci-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="['ci-view-toggle-btn', viewMode === 'list' && 'ci-view-toggle-btn--active']" title="List view" @click="viewMode = 'list'">
|
||||
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-if="pending && !usingMock" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="n in 6" :key="n">
|
||||
<div class="h-32 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- ═══ Card View ═══ -->
|
||||
<div v-if="viewMode === 'card'" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink v-for="c in customers" :key="c.id" :to="`/customers/${c.id}`">
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div class="space-y-3">
|
||||
@@ -115,16 +211,52 @@ const customerTypeColor = (type: string) =>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UAvatar :alt="customerName(c)" size="md" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900 truncate">{{ customerName(c) }}</p>
|
||||
<p class="font-semibold text-[var(--text-primary)] truncate">{{ customerName(c) }}</p>
|
||||
<p class="text-sm text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs" class="flex-shrink-0">
|
||||
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all"
|
||||
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
|
||||
title="Toggle favorite"
|
||||
@click.prevent.stop="toggleFavorite(c.id)"
|
||||
>
|
||||
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
|
||||
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual fields -->
|
||||
<!-- Mock client details -->
|
||||
<template v-if="c._mock">
|
||||
<div class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
<span>{{ c._mock.phone }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Agent</span>
|
||||
<span>{{ c._mock.agent }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-500">Payment</span>
|
||||
<span
|
||||
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
|
||||
>{{ c._mock.paymentStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c._mock.tags.length" class="flex gap-1 flex-wrap pt-1">
|
||||
<span v-for="tag in c._mock.tags" :key="tag" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{{ tag }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Individual fields (API) -->
|
||||
<template v-else>
|
||||
<div v-if="c.customer_type !== 'corporate'" class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
@@ -140,7 +272,7 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate fields -->
|
||||
<!-- Corporate fields (API) -->
|
||||
<div v-else class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Name</span>
|
||||
@@ -155,6 +287,7 @@ const customerTypeColor = (type: string) =>
|
||||
<span>{{ c.legal_rep_name ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
@@ -166,6 +299,83 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ List View ═══ -->
|
||||
<div v-else class="ci-list-card">
|
||||
<table class="ci-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ci-list-th" style="width: 280px;">Customer</th>
|
||||
<th class="ci-list-th">Type</th>
|
||||
<th class="ci-list-th">Email</th>
|
||||
<th class="ci-list-th">Phone</th>
|
||||
<th class="ci-list-th">Agent</th>
|
||||
<th class="ci-list-th" style="text-align: right;">Policies</th>
|
||||
<th class="ci-list-th" style="text-align: right;">Premium</th>
|
||||
<th class="ci-list-th">Payment</th>
|
||||
<th class="ci-list-th" style="width: 40px;" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="customers.length === 0">
|
||||
<td colspan="9" class="ci-list-empty">
|
||||
<UIcon name="i-heroicons-users" class="w-10 h-10 mx-auto mb-3" />
|
||||
<p class="text-base font-medium">No customers found</p>
|
||||
<p class="text-sm">Try adjusting your search or create a new customer</p>
|
||||
</td>
|
||||
</tr>
|
||||
<NuxtLink
|
||||
v-for="c in customers"
|
||||
:key="c.id"
|
||||
:to="`/customers/${c.id}`"
|
||||
custom
|
||||
v-slot="{ navigate }"
|
||||
>
|
||||
<tr class="ci-list-row" @click="navigate" style="cursor: pointer;">
|
||||
<td class="ci-list-td">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<UAvatar :alt="customerName(c)" size="xs" />
|
||||
<span class="font-medium text-[var(--text-primary)] truncate">{{ customerName(c) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="ci-list-td">
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Indiv' }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c.email ?? '—' }}</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c.phone ?? '—' }}</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c._mock ? c._mock.agent : '—' }}</td>
|
||||
<td class="ci-list-td" style="text-align: right;">
|
||||
{{ c._mock ? c._mock.policies.length : '—' }}
|
||||
</td>
|
||||
<td class="ci-list-td" style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ c._mock ? fmtMoney(c._mock.policies.reduce((s: number, p: any) => s + p.premium, 0)) + '/yr' : '—' }}
|
||||
</td>
|
||||
<td class="ci-list-td">
|
||||
<span
|
||||
v-if="c._mock"
|
||||
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
|
||||
>{{ c._mock.paymentStatus }}</span>
|
||||
<span v-else class="ci-list-td--secondary">—</span>
|
||||
</td>
|
||||
<td class="ci-list-td" style="text-align: center;">
|
||||
<button
|
||||
type="button"
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all"
|
||||
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
|
||||
title="Toggle favorite"
|
||||
@click.prevent.stop="toggleFavorite(c.id)"
|
||||
>
|
||||
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</NuxtLink>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="meta && meta.total_pages > 1" class="flex justify-center">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
@@ -176,3 +386,99 @@ const customerTypeColor = (type: string) =>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ── */
|
||||
.ci-root { display: flex; flex-direction: column; gap: 16px; }
|
||||
.ci-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ci-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.ci-title { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); line-height: 1; }
|
||||
.ci-count-badge { font-size: 11px; font-weight: 700; color: #01696f; background: rgba(1,105,111,0.08); padding: 2px 9px; border-radius: 10px; }
|
||||
.ci-header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.ci-btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; font-size: 12px; font-weight: 600; color: #fff; background: #01696f; border: none; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||
.ci-btn-primary:hover { background: #015258; }
|
||||
|
||||
/* ── Filter bar ── */
|
||||
.ci-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ci-filter-search { width: 260px; }
|
||||
.ci-filter-select { width: auto; min-width: 130px; }
|
||||
|
||||
/* ── View toggle ── */
|
||||
.ci-view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.ci-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;
|
||||
}
|
||||
.ci-view-toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ci-view-toggle-btn--active {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ── List view card ── */
|
||||
.ci-list-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ci-list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.ci-list-th {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8a8a86;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ci-list-row {
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.ci-list-row:hover {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
.ci-list-row:not(:last-child) .ci-list-td {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.ci-list-td {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
padding: 10px 14px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ci-list-td--secondary {
|
||||
color: #8a8a86;
|
||||
}
|
||||
.ci-list-empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,285 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $customer } = useNuxtApp()
|
||||
|
||||
const customerType = ref<'individual' | 'corporate'>('individual')
|
||||
|
||||
// individual form
|
||||
const individualForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
birth_date: '',
|
||||
gender: '',
|
||||
document_id: ''
|
||||
})
|
||||
|
||||
// corporate form
|
||||
const corporateForm = ref({
|
||||
legal_name: '',
|
||||
commercial_name: '',
|
||||
ruc: '',
|
||||
legal_rep_name: '',
|
||||
legal_rep_document_id: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
const genderItems = ref<SelectItem[]>([
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' }
|
||||
])
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (customerType.value === 'individual') {
|
||||
return individualForm.value.first_name &&
|
||||
individualForm.value.last_name &&
|
||||
individualForm.value.email &&
|
||||
individualForm.value.document_id
|
||||
}
|
||||
return corporateForm.value.legal_name && corporateForm.value.ruc
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const isIndividual = customerType.value === 'individual'
|
||||
const data = await $customer(isIndividual ? '/customers' : '/customers/corporate', {
|
||||
method: 'POST',
|
||||
body: isIndividual ? individualForm.value : corporateForm.value
|
||||
}) as any
|
||||
toast.add({ title: 'Customer created successfully', color: 'green' })
|
||||
router.push(`/customers/${data.data.id}`)
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
title: 'Failed to create customer',
|
||||
description: e?.data?.errors ? JSON.stringify(e.data.errors) : e.message,
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
const route = useRoute()
|
||||
await navigateTo({ path: '/registration/client', query: route.query }, { replace: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">
|
||||
Back to Customers
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">New Customer</h1>
|
||||
<p class="text-gray-500 text-sm">Create a new customer record</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="flex gap-4 max-w-2xl">
|
||||
<div
|
||||
v-for="type in ['individual', 'corporate']"
|
||||
:key="type"
|
||||
class="flex-1 border-2 rounded-xl p-4 text-center transition-all cursor-pointer"
|
||||
:class="customerType === type
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'"
|
||||
@click="customerType = type as any"
|
||||
>
|
||||
<UIcon
|
||||
:name="type === 'individual' ? 'i-heroicons-user' : 'i-heroicons-building-office'"
|
||||
class="w-8 h-8 mx-auto mb-2"
|
||||
:class="customerType === type ? 'text-primary-500' : 'text-gray-400'"
|
||||
/>
|
||||
<p
|
||||
class="font-medium text-sm"
|
||||
:class="customerType === type ? 'text-primary-700' : 'text-gray-600'"
|
||||
>
|
||||
{{ type === 'individual' ? 'Individual' : 'Corporate' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual form -->
|
||||
<UCard v-if="customerType === 'individual'" class="max-w-2xl">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user-plus" class="w-4 h-4" />
|
||||
Individual Customer
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="First Name" required>
|
||||
<UInput v-model="individualForm.first_name" placeholder="Juan" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Last Name" required>
|
||||
<UInput v-model="individualForm.last_name" placeholder="Pérez" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Document ID" required>
|
||||
<UInput
|
||||
v-model="individualForm.document_id"
|
||||
placeholder="V-12345678"
|
||||
icon="i-heroicons-identification"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Email" required>
|
||||
<UInput
|
||||
v-model="individualForm.email"
|
||||
type="email"
|
||||
placeholder="juan@example.com"
|
||||
icon="i-heroicons-envelope"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Phone">
|
||||
<UInput
|
||||
v-model="individualForm.phone"
|
||||
placeholder="+507 6000-0000"
|
||||
icon="i-heroicons-phone"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Birth Date">
|
||||
<UInput v-model="individualForm.birth_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Gender">
|
||||
<USelect v-model="individualForm.gender" :items="genderItems" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton color="gray" variant="soft">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-heroicons-check"
|
||||
:loading="submitting"
|
||||
:disabled="!isValid"
|
||||
@click="submit"
|
||||
>
|
||||
Create Customer
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<!-- Corporate form -->
|
||||
<UCard v-else class="max-w-2xl">
|
||||
<template #header>
|
||||
<p class="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" />
|
||||
Corporate Customer
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Legal Name" required>
|
||||
<UInput
|
||||
v-model="corporateForm.legal_name"
|
||||
placeholder="Empresa S.A."
|
||||
icon="i-heroicons-building-office"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Commercial Name">
|
||||
<UInput
|
||||
v-model="corporateForm.commercial_name"
|
||||
placeholder="Empresa"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="RUC" required>
|
||||
<UInput
|
||||
v-model="corporateForm.ruc"
|
||||
placeholder="1234567-1-123456"
|
||||
icon="i-heroicons-identification"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Legal Representative">
|
||||
<UInput
|
||||
v-model="corporateForm.legal_rep_name"
|
||||
placeholder="Juan Pérez"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Legal Rep Document ID">
|
||||
<UInput
|
||||
v-model="corporateForm.legal_rep_document_id"
|
||||
placeholder="V-12345678"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Email">
|
||||
<UInput
|
||||
v-model="corporateForm.email"
|
||||
type="email"
|
||||
placeholder="contacto@empresa.com"
|
||||
icon="i-heroicons-envelope"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Phone">
|
||||
<UInput
|
||||
v-model="corporateForm.phone"
|
||||
placeholder="+507 300-0000"
|
||||
icon="i-heroicons-phone"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Address">
|
||||
<UInput
|
||||
v-model="corporateForm.address"
|
||||
placeholder="Av. Balboa, Panama City"
|
||||
icon="i-heroicons-map-pin"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NuxtLink to="/customers">
|
||||
<UButton color="gray" variant="soft">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-heroicons-check"
|
||||
:loading="submitting"
|
||||
:disabled="!isValid"
|
||||
@click="submit"
|
||||
>
|
||||
Create Corporate Customer
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
<div class="p-8 text-sm text-[var(--text-muted)]">Redirecting...</div>
|
||||
</template>
|
||||
|
||||
777
app/pages/home2.vue
Normal file
777
app/pages/home2.vue
Normal file
@@ -0,0 +1,777 @@
|
||||
<script setup lang="ts">
|
||||
import type { WelcomeDashboardKpi } from '~/types/welcome-dashboard'
|
||||
import {
|
||||
DASHBOARD_PRESET_ORDER,
|
||||
DASHBOARD_ROLE_PRESETS,
|
||||
DASHBOARD_WIDGETS,
|
||||
type DashboardRolePresetId,
|
||||
type DashboardWidgetId
|
||||
} from '~/composables/useDashboardHomeWidgets'
|
||||
|
||||
const { saved: homeBranding } = useBrokerageBranding()
|
||||
const welcome = useWelcomeDashboard()
|
||||
|
||||
const {
|
||||
widgets,
|
||||
widgetOrder,
|
||||
layoutUnlocked,
|
||||
activePreset,
|
||||
isPresetDirty,
|
||||
applyPreset,
|
||||
setWidget,
|
||||
reapplySelectedPreset,
|
||||
reorderWidgets
|
||||
} = useDashboardHomeWidgets()
|
||||
|
||||
const dashConfigOpen = ref(false)
|
||||
const draggingWidget = ref<DashboardWidgetId | null>(null)
|
||||
|
||||
function onDragStart(wid: DashboardWidgetId, e: DragEvent) {
|
||||
if (!layoutUnlocked.value) { e.preventDefault(); return }
|
||||
draggingWidget.value = wid
|
||||
try { e.dataTransfer?.setData('text/plain', wid); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' } catch { /* */ }
|
||||
}
|
||||
function onDragEnd() { draggingWidget.value = null }
|
||||
function onDropSection(target: DashboardWidgetId, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (!layoutUnlocked.value) return
|
||||
const raw = draggingWidget.value ?? e.dataTransfer?.getData('text/plain')
|
||||
const from = raw as DashboardWidgetId | undefined
|
||||
if (!from || from === target) return
|
||||
reorderWidgets(from, target)
|
||||
draggingWidget.value = null
|
||||
}
|
||||
function toggleLayoutUnlock() { layoutUnlocked.value = !layoutUnlocked.value }
|
||||
|
||||
const presetSelectItems = computed(() =>
|
||||
DASHBOARD_PRESET_ORDER.map((id) => ({ label: DASHBOARD_ROLE_PRESETS[id].label, value: id }))
|
||||
)
|
||||
const activePresetHint = computed(() => DASHBOARD_ROLE_PRESETS[activePreset.value]?.hint ?? '')
|
||||
|
||||
watch(() => welcome.value.productName, (name) => {
|
||||
if (typeof document !== 'undefined') document.title = name ? `Home \u00b7 ${name}` : 'Home'
|
||||
}, { immediate: true })
|
||||
|
||||
/* ---- sparkline helpers ---- */
|
||||
const kpiSparkSeries: Record<string, number[]> = {
|
||||
ms: [42, 44, 41, 46, 48, 45, 49, 52, 50, 54, 53, 56],
|
||||
mr: [58, 59, 57, 61, 62, 60, 63, 65, 64, 67, 66, 68],
|
||||
ren: [72, 70, 74, 73, 75, 76, 74, 77, 78, 76, 79, 80],
|
||||
late: [55, 52, 54, 50, 48, 47, 45, 44, 43, 42, 40, 38]
|
||||
}
|
||||
|
||||
function smoothSparklinePath(points: number[], w = 112, h = 32, pad = 2) {
|
||||
const max = Math.max(...points); const min = Math.min(...points); const r = max - min || 1
|
||||
const pts = points.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2)
|
||||
}))
|
||||
if (pts.length < 2) return ''
|
||||
let d = `M ${pts[0]!.x},${pts[0]!.y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i]!; const p1 = pts[i + 1]!; const cx = (p0.x + p1.x) / 2
|
||||
d += ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
function smoothSparklineArea(points: number[], w = 112, h = 32, pad = 2) {
|
||||
const path = smoothSparklinePath(points, w, h, pad)
|
||||
if (!path) return ''
|
||||
const pts = points.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - Math.min(...points)) / (Math.max(...points) - Math.min(...points) || 1)) * (h - pad * 2)
|
||||
}))
|
||||
return `${path} L ${pts[pts.length - 1]!.x},${h} L ${pts[0]!.x},${h} Z`
|
||||
}
|
||||
|
||||
/* ---- GWP chart ---- */
|
||||
const gwpTrend = [
|
||||
{ m: 'Oct', v: 72, display: '$4.52M' }, { m: 'Nov', v: 68, display: '$4.28M' },
|
||||
{ m: 'Dec', v: 76, display: '$4.71M' }, { m: 'Jan', v: 74, display: '$4.61M' },
|
||||
{ m: 'Feb', v: 81, display: '$4.98M' }, { m: 'Mar', v: 88, display: '$5.41M' }
|
||||
] as const
|
||||
const gwpChartLayout = { viewW: 400, viewH: 152, padX: 8, padY: 14 } as const
|
||||
const gwpChartModel = computed(() => {
|
||||
const pts = gwpTrend.map((b) => b.v)
|
||||
const { viewW, viewH, padX, padY } = gwpChartLayout
|
||||
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
||||
const maxV = Math.max(...pts, 1); const span = maxV || 1
|
||||
const points = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
||||
y: padY + (1 - p / span) * innerH, v: p
|
||||
}))
|
||||
const bottomY = padY + innerH; const first = points[0]!; const last = points[points.length - 1]!
|
||||
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
||||
let lineD = `M ${first.x},${first.y}`
|
||||
for (let i = 1; i < points.length; i++) { areaD += ` L ${points[i]!.x},${points[i]!.y}`; lineD += ` L ${points[i]!.x},${points[i]!.y}` }
|
||||
areaD += ` L ${last.x},${bottomY} Z`
|
||||
const gridYs = [0, 0.5, 1].map((t) => padY + t * innerH)
|
||||
return { areaPath: areaD, linePath: lineD, points, gridYs, viewW, viewH, padX, innerW }
|
||||
})
|
||||
const gwpLatest = computed(() => gwpTrend[gwpTrend.length - 1]!)
|
||||
|
||||
/* ---- Pipeline data ---- */
|
||||
const QUOTED_PIPELINE_PREMIUM_M = 6.2
|
||||
const quotedPipelineSummaryCards = [
|
||||
{ label: 'Total book', value: '$42.8M', hint: 'In force' },
|
||||
{ label: 'Quoted pipeline', value: '$6.2M', hint: 'Open quotes' },
|
||||
{ label: 'YTD new sales', value: '$18.4M', hint: 'Bound new biz' }
|
||||
] as const
|
||||
const pipelineMixSegments = [
|
||||
{ label: 'Commercial', pct: 38 }, { label: 'Personal', pct: 29 },
|
||||
{ label: 'Benefits', pct: 22 }, { label: 'Other', pct: 11 }
|
||||
] as const
|
||||
const pipelineMixRows = computed(() =>
|
||||
pipelineMixSegments.map((row) => ({ ...row, premiumM: (QUOTED_PIPELINE_PREMIUM_M * row.pct) / 100 }))
|
||||
)
|
||||
function formatPremiumM(n: number) { return `$${n.toFixed(2)}M` }
|
||||
|
||||
/* ---- Tone helpers ---- */
|
||||
function changeToneClass(tone: WelcomeDashboardKpi['changeTone']) {
|
||||
switch (tone) {
|
||||
case 'positive': return 'h2-tone-pos'
|
||||
case 'negative': return 'h2-tone-neg'
|
||||
default: return 'h2-tone-neutral'
|
||||
}
|
||||
}
|
||||
|
||||
type AlertToneMeta = { icon: string; label: string; railStyle: string; iconColor: string; bg: string }
|
||||
function alertToneMeta(tone: string): AlertToneMeta {
|
||||
switch (tone) {
|
||||
case 'error': return { icon: 'i-heroicons-exclamation-circle', label: 'Critical', railStyle: 'background:#c13838', iconColor: 'text-rose-600', bg: 'bg-rose-50/60' }
|
||||
case 'warning': return { icon: 'i-heroicons-exclamation-triangle', label: 'Attention', railStyle: 'background:#c27b1a', iconColor: 'text-amber-600', bg: 'bg-amber-50/60' }
|
||||
case 'success': return { icon: 'i-heroicons-check-circle', label: 'Update', railStyle: 'background:#0f7b5f', iconColor: 'text-emerald-700', bg: 'bg-emerald-50/60' }
|
||||
default: return { icon: 'i-heroicons-information-circle', label: 'Notice', railStyle: 'background:#8c857d', iconColor: 'text-stone-500', bg: 'bg-stone-100/60' }
|
||||
}
|
||||
}
|
||||
const alertsWithMeta = computed(() => welcome.value.alerts.map((a) => ({ ...a, meta: alertToneMeta(a.tone) })))
|
||||
|
||||
/* ---- Operations command bar data ---- */
|
||||
const opsMetrics = [
|
||||
{ id: 'prod', label: 'Production MTD', value: '$1.24M', target: '$1.18M', status: 'on-track' as const, icon: 'i-heroicons-banknotes' },
|
||||
{ id: 'ren', label: 'Renewals due', value: '23', target: 'next 30d', status: 'attention' as const, icon: 'i-heroicons-arrow-path' },
|
||||
{ id: 'coll', label: 'Collections at risk', value: '$184K', target: '3 accounts', status: 'warning' as const, icon: 'i-heroicons-exclamation-triangle' },
|
||||
{ id: 'claims', label: 'Claims pending', value: '7', target: 'avg 4.2d open', status: 'neutral' as const, icon: 'i-heroicons-shield-exclamation' },
|
||||
{ id: 'svc', label: 'Service backlog', value: '12', target: 'SLA: 94%', status: 'on-track' as const, icon: 'i-heroicons-inbox-stack' }
|
||||
]
|
||||
|
||||
function opsStatusClass(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
||||
switch (status) {
|
||||
case 'on-track': return 'h2-ops-on-track'
|
||||
case 'attention': return 'h2-ops-attention'
|
||||
case 'warning': return 'h2-ops-warning'
|
||||
default: return 'h2-ops-neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function opsStatusDotStyle(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
||||
switch (status) {
|
||||
case 'on-track': return 'background:#0f7b5f'
|
||||
case 'attention': return 'background:#0d5c63'
|
||||
case 'warning': return 'background:#c27b1a'
|
||||
default: return 'background:#8c857d'
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Quote lines ---- */
|
||||
const quoteLines = [
|
||||
{ to: '/quotes/auto', label: 'Auto', hint: 'Motor, fleet & bind', icon: 'i-heroicons-truck' },
|
||||
{ to: '/quotes/health', label: 'Health', hint: 'Collective & individual', icon: 'i-heroicons-heart' },
|
||||
{ to: '/quotes/life', label: 'Life', hint: 'Individual & corporate', icon: 'i-heroicons-user-group' },
|
||||
{ to: '/quotes/general-risk', label: 'General risk', hint: 'Liability & specialty', icon: 'i-heroicons-building-office-2' },
|
||||
{ to: '/quotes/custom', label: 'Custom', hint: 'Ad hoc products', icon: 'i-heroicons-puzzle-piece' }
|
||||
] as const
|
||||
|
||||
/* ---- Time ---- */
|
||||
const timeGreeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
})
|
||||
const currentDate = computed(() =>
|
||||
new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
|
||||
)
|
||||
|
||||
/* ---- Segment colors (petroleum palette) ---- */
|
||||
/* Segment colors as inline styles (Tailwind v4 doesn't resolve teal) */
|
||||
const segColorStyles = ['background:#0d5c63', 'background:#1a8a8a', 'background:#2dd4bf', 'background:#a8a29e']
|
||||
const segDotStyles = ['background:#0d5c63;outline:2px solid rgba(13,92,99,0.2);outline-offset:1px', 'background:#1a8a8a;outline:2px solid rgba(26,138,138,0.2);outline-offset:1px', 'background:#2dd4bf;outline:2px solid rgba(45,212,191,0.2);outline-offset:1px', 'background:#a8a29e;outline:2px solid rgba(168,162,158,0.2);outline-offset:1px']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h2 relative min-h-full pb-12">
|
||||
|
||||
<HomeDashboardWidgetBlocks
|
||||
:widget-order="widgetOrder"
|
||||
:widgets="widgets"
|
||||
:layout-unlocked="layoutUnlocked"
|
||||
:dragging-widget="draggingWidget"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drop="onDropSection"
|
||||
>
|
||||
<!-- ==================== HERO: Operations Command Bar ==================== -->
|
||||
<template #hero>
|
||||
<div class="space-y-4">
|
||||
<!-- Slim greeting strip -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-[var(--h2-fg)]">
|
||||
{{ timeGreeting }}, {{ welcome.greetingName }}
|
||||
</h1>
|
||||
<span class="text-xs text-[var(--h2-muted)]">{{ currentDate }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/onboarding">
|
||||
<UButton size="sm" color="neutral" variant="outline" class="h2-btn-outline" icon="i-heroicons-arrow-trending-up">
|
||||
Pipeline
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/quotes">
|
||||
<UButton size="sm" color="primary" class="h2-btn-primary" icon="i-heroicons-document-text">
|
||||
New quote
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operations rail: 5-cell command strip -->
|
||||
<div class="h2-card h2-card-flush grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="(op, i) in opsMetrics"
|
||||
:key="op.id"
|
||||
class="relative flex items-start gap-3 p-4"
|
||||
:class="[
|
||||
i < opsMetrics.length - 1 ? 'h2-cell-border' : '',
|
||||
opsStatusClass(op.status)
|
||||
]"
|
||||
>
|
||||
<!-- Status rail (left edge accent) -->
|
||||
<div class="absolute inset-y-2 left-0 rounded-full" :style="opsStatusDotStyle(op.status) + ';width:3px'" />
|
||||
|
||||
<div class="pl-2.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon :name="op.icon" class="h-3.5 w-3.5 text-[var(--h2-muted)]" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ op.label }}</p>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ op.value }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">{{ op.target }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== MILESTONE ==================== -->
|
||||
<template #milestone>
|
||||
<div class="h2-card h2-rail-success flex flex-wrap items-center gap-4 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--h2-success)]/10">
|
||||
<UIcon name="i-heroicons-check-badge" class="h-4 w-4 text-[var(--h2-success)]" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-[var(--h2-success)]">On track</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-[var(--h2-border)]" />
|
||||
<span class="text-sm text-[var(--h2-fg)]">
|
||||
Premium <strong class="font-semibold">$1.24M</strong> vs $1.18M
|
||||
</span>
|
||||
<div class="h-4 w-px bg-[var(--h2-border)] hidden sm:block" />
|
||||
<span class="text-sm text-[var(--h2-fg)] hidden sm:inline">
|
||||
Policies <strong class="font-semibold">42</strong> / 40
|
||||
</span>
|
||||
<span class="ml-auto text-[11px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">MTD plan</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== PERFORMANCE KPIs ==================== -->
|
||||
<template #performance>
|
||||
<section class="space-y-4" aria-labelledby="perf-h2">
|
||||
<div class="h2-section-header">
|
||||
<h2 id="perf-h2" class="h2-section-title">Today at a glance</h2>
|
||||
<p class="h2-section-sub">Headline operational metrics</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="k in welcome.performanceKpis"
|
||||
:key="k.id"
|
||||
class="h2-card group h2-rail-accent overflow-hidden p-4 transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
||||
<div class="mt-1.5 flex items-end gap-2.5">
|
||||
<p class="font-mono text-2xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">
|
||||
{{ k.value }}
|
||||
</p>
|
||||
<p v-if="k.change" class="mb-0.5 text-xs font-semibold" :class="changeToneClass(k.changeTone)">
|
||||
{{ k.change }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="k.hint" class="mt-1 text-[11px] leading-snug text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||||
|
||||
<div class="mt-2.5 h-7 w-full">
|
||||
<svg class="h-full w-full" viewBox="0 0 112 32" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`sg2-${k.id}`" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.16" />
|
||||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path v-if="kpiSparkSeries[k.id]" :d="smoothSparklineArea(kpiSparkSeries[k.id]!)" :fill="`url(#sg2-${k.id})`" />
|
||||
<path
|
||||
v-if="kpiSparkSeries[k.id]" :d="smoothSparklinePath(kpiSparkSeries[k.id]!)"
|
||||
fill="none" stroke="var(--h2-accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="opacity-60 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== TASKS & ALERTS ==================== -->
|
||||
<template #tasks_alerts>
|
||||
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<!-- Tasks -->
|
||||
<div class="h2-card overflow-hidden">
|
||||
<div class="h2-card-header">
|
||||
<div class="h2-icon-box"><UIcon name="i-heroicons-clipboard-document-check" class="h-4 w-4" /></div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[var(--h2-fg)]">Today's tasks</p>
|
||||
<p class="text-[11px] text-[var(--h2-muted)]">{{ welcome.dailyTasks.length }} items prioritized</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="h2-list">
|
||||
<li
|
||||
v-for="task in welcome.dailyTasks"
|
||||
:key="task.id"
|
||||
class="h2-list-item"
|
||||
>
|
||||
<div
|
||||
class="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
:class="task.emphasis ? 'bg-[var(--h2-warning)]' : 'bg-[var(--h2-border)]'"
|
||||
/>
|
||||
<span
|
||||
class="text-[13px] leading-snug"
|
||||
:class="task.emphasis ? 'font-medium text-[var(--h2-fg)]' : 'text-[var(--h2-fg-secondary)]'"
|
||||
>{{ task.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div class="h2-card overflow-hidden">
|
||||
<div class="h2-card-header">
|
||||
<div class="h2-icon-box h2-icon-box-error"><UIcon name="i-heroicons-bell-alert" class="h-4 w-4" /></div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[var(--h2-fg)]">Alerts</p>
|
||||
<p class="text-[11px] text-[var(--h2-muted)]">Exceptions needing attention</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-px px-3 pb-3">
|
||||
<div
|
||||
v-for="alert in alertsWithMeta"
|
||||
:key="alert.id"
|
||||
class="h2-alert-row"
|
||||
:class="alert.meta.bg"
|
||||
>
|
||||
<!-- Status rail -->
|
||||
<div class="absolute inset-y-1.5 left-0 rounded-full" :style="alert.meta.railStyle + ';width:3px'" />
|
||||
<UIcon :name="alert.meta.icon" class="mt-0.5 h-4 w-4 shrink-0 pl-2" :class="alert.meta.iconColor" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] font-bold uppercase tracking-wider" :class="alert.meta.iconColor">{{ alert.meta.label }}</p>
|
||||
<p class="mt-0.5 text-[13px] leading-snug text-[var(--h2-fg)]">{{ alert.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== CHARTS ==================== -->
|
||||
<template #charts>
|
||||
<section class="grid gap-4 lg:grid-cols-5" aria-labelledby="ch-h2">
|
||||
<!-- GWP -->
|
||||
<div class="lg:col-span-3 h2-card overflow-hidden">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 px-5 pt-5">
|
||||
<div>
|
||||
<h2 id="ch-h2" class="text-sm font-semibold text-[var(--h2-fg)]">GWP written</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Trailing 6 months</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h2-badge-success">+6.2%</span>
|
||||
<span class="font-mono text-lg font-bold tabular-nums text-[var(--h2-fg)]">{{ gwpLatest.display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pb-2 pt-3">
|
||||
<div class="overflow-hidden rounded-lg bg-[var(--h2-surface-inset)] ring-1 ring-[var(--h2-border-strong)]">
|
||||
<svg class="h-auto w-full" :viewBox="`0 0 ${gwpChartModel.viewW} ${gwpChartModel.viewH}`" role="img">
|
||||
<title>Gross written premium trend</title>
|
||||
<defs>
|
||||
<linearGradient id="gwp2" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.2" />
|
||||
<stop offset="60%" stop-color="var(--h2-accent)" stop-opacity="0.04" />
|
||||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line v-for="(gy, i) in gwpChartModel.gridYs" :key="i" class="stroke-[var(--h2-border)]" stroke-width="1"
|
||||
:x1="gwpChartModel.padX" :y1="gy" :x2="gwpChartModel.padX + gwpChartModel.innerW" :y2="gy" />
|
||||
<path :d="gwpChartModel.areaPath" fill="url(#gwp2)" />
|
||||
<path :d="gwpChartModel.linePath" fill="none" stroke="var(--h2-accent)" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g v-for="(pt, i) in gwpChartModel.points" :key="i">
|
||||
<circle :cx="pt.x" :cy="pt.y" r="4.5" fill="var(--h2-surface)" stroke="var(--h2-accent)" stroke-width="2" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="flex justify-between border-t border-[var(--h2-border)] px-3 pb-2 pt-1.5">
|
||||
<div v-for="row in gwpTrend" :key="row.m" class="flex-1 text-center">
|
||||
<p class="font-mono text-[10px] font-semibold tabular-nums text-[var(--h2-fg)]">{{ row.display }}</p>
|
||||
<p class="text-[10px] text-[var(--h2-muted)]">{{ row.m }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<div class="lg:col-span-2 h2-card flex flex-col overflow-hidden">
|
||||
<div class="px-5 pt-5">
|
||||
<h2 class="text-sm font-semibold text-[var(--h2-fg)]">Pipeline</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Book, open quotes & YTD</p>
|
||||
</div>
|
||||
<!-- Summary trio -->
|
||||
<div class="mx-4 mt-4 grid grid-cols-3 overflow-hidden rounded-lg ring-1 ring-[var(--h2-border-strong)]">
|
||||
<div v-for="item in quotedPipelineSummaryCards" :key="item.label" class="bg-[var(--h2-surface-inset)] px-3 py-3 text-center ring-1 ring-[var(--h2-border)]">
|
||||
<p class="font-mono text-base font-bold tabular-nums text-[var(--h2-fg)]">{{ item.value }}</p>
|
||||
<p class="mt-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">{{ item.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Segment mix -->
|
||||
<div class="mt-4 flex-1 px-5 pb-5">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Segment mix</p>
|
||||
<div class="mt-2.5 flex h-2.5 w-full overflow-hidden rounded-md" style="outline:1px solid var(--h2-border)">
|
||||
<div v-for="(row, i) in pipelineMixRows" :key="row.label" class="h-full" :style="segColorStyles[i] + ';width:' + row.pct + '%'" />
|
||||
</div>
|
||||
<div class="mt-3.5 space-y-2">
|
||||
<div v-for="(row, i) in pipelineMixRows" :key="row.label" class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 shrink-0 rounded-sm" :style="segDotStyles[i]" />
|
||||
<span class="flex-1 truncate text-[12px] font-medium text-[var(--h2-fg)]">{{ row.label }}</span>
|
||||
<span class="font-mono text-[12px] tabular-nums text-[var(--h2-muted)]">{{ formatPremiumM(row.premiumM) }}</span>
|
||||
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--h2-muted)]">{{ row.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== BROKERAGE HEALTH ==================== -->
|
||||
<template #brokerage_health>
|
||||
<section v-if="welcome.ceoKpis?.length" class="space-y-4">
|
||||
<div class="h2-section-header">
|
||||
<h2 class="h2-section-title">Brokerage health</h2>
|
||||
<p class="h2-section-sub">YTD and trailing measures</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="k in welcome.ceoKpis" :key="k.id" class="h2-card h2-rail-accent p-4 transition-all hover:shadow-md">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
||||
<p class="mt-1.5 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ k.value }}</p>
|
||||
<p v-if="k.change" class="mt-1 text-xs font-semibold" :class="changeToneClass(k.changeTone)">{{ k.change }}</p>
|
||||
<p v-if="k.hint" class="mt-1 text-[11px] text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== QUOTES LINE ==================== -->
|
||||
<template #quotes_line>
|
||||
<section class="space-y-4" aria-labelledby="h2q">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="h2-section-header">
|
||||
<h2 id="h2q" class="h2-section-title">Quotes</h2>
|
||||
<p class="h2-section-sub">Start a new quote by line of business</p>
|
||||
</div>
|
||||
<NuxtLink to="/quotes" class="text-sm font-semibold h2-text-accent transition hover:h2-text-accent-hover">
|
||||
View all →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<NuxtLink v-for="line in quoteLines" :key="line.to" :to="line.to" class="group">
|
||||
<div class="h2-card h2-card-hover flex h-full flex-col items-center justify-center px-4 py-5 text-center transition-all duration-200">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
||||
<UIcon :name="line.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="mt-3 text-sm font-semibold text-[var(--h2-fg)]">{{ line.label }}</p>
|
||||
<p class="mt-0.5 text-[11px] leading-snug text-[var(--h2-muted)]">{{ line.hint }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== QUICK LINKS ==================== -->
|
||||
<template #quick_links>
|
||||
<section class="space-y-4">
|
||||
<div class="h2-section-header">
|
||||
<h2 class="h2-section-title">Quick links</h2>
|
||||
<p class="h2-section-sub">Jump to operational areas</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="link in welcome.quickLinks" :key="link.to" :to="link.to"
|
||||
class="group h2-card h2-card-hover flex gap-3.5 p-4 transition-all duration-200"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
||||
<UIcon :name="link.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--h2-fg)] transition-colors group-hover:h2-text-accent">{{ link.label }}</p>
|
||||
<p class="mt-0.5 text-[12px] leading-snug text-[var(--h2-fg-secondary)]">{{ link.description }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</HomeDashboardWidgetBlocks>
|
||||
|
||||
<!-- Layout controls -->
|
||||
<div class="mx-auto mt-12 flex max-w-6xl flex-col gap-3 border-t border-[var(--h2-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
:icon="layoutUnlocked ? 'i-heroicons-arrows-up-down' : 'i-heroicons-lock-closed'"
|
||||
:color="layoutUnlocked ? 'primary' : 'neutral'" variant="soft" class="h2-btn-outline"
|
||||
@click="toggleLayoutUnlock"
|
||||
>{{ layoutUnlocked ? 'Reorder on' : 'Reorder off' }}</UButton>
|
||||
<p class="max-w-md text-xs text-[var(--h2-muted)]">Drag blocks by the grip when reorder is on.</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-squares-2x2" color="primary" class="h2-btn-primary shrink-0" @click="dashConfigOpen = true">
|
||||
Layout & widgets
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Slideover -->
|
||||
<USlideover v-model:open="dashConfigOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex h-full max-w-md flex-col bg-[var(--h2-surface)] sm:max-w-lg">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--h2-border)] p-6">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[var(--h2-fg)]">Dashboard layout</h2>
|
||||
<p class="mt-1 text-sm text-[var(--h2-fg-secondary)]">Choose a role preset or toggle sections.</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" class="shrink-0" aria-label="Close" @click="dashConfigOpen = false" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Role preset</p>
|
||||
<USelect :model-value="activePreset" :items="presetSelectItems" value-key="value" label-key="label" class="mt-2 w-full"
|
||||
@update:model-value="applyPreset($event as DashboardRolePresetId)" />
|
||||
<p class="mt-2 text-xs text-[var(--h2-muted)]">{{ activePresetHint }}</p>
|
||||
<UButton v-if="isPresetDirty" size="xs" color="neutral" variant="soft" class="mt-2" @click="reapplySelectedPreset">Reset to preset</UButton>
|
||||
</div>
|
||||
<div class="border-t border-[var(--h2-border)] pt-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Sections</p>
|
||||
<ul class="mt-3 space-y-4">
|
||||
<li v-for="w in DASHBOARD_WIDGETS" :key="w.id" class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-[var(--h2-fg)]">{{ w.label }}</p>
|
||||
<p class="text-xs text-[var(--h2-muted)]">{{ w.description }}</p>
|
||||
</div>
|
||||
<USwitch :model-value="widgets[w.id]" @update:model-value="setWidget(w.id, $event)" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
HOME 2 — PETROLEUM / STONE DESIGN SYSTEM (scoped)
|
||||
===================================================================== */
|
||||
|
||||
/* ---- Palette ---- */
|
||||
.h2 {
|
||||
/* Petroleum accent scale */
|
||||
--h2-accent: #0d5c63;
|
||||
--h2-accent-hover: #0a4a50;
|
||||
--h2-accent-muted: #1a8a8a;
|
||||
--h2-accent-soft: #0d5c63 / 0.08;
|
||||
|
||||
/* Surfaces — stone/warm-gray with real depth */
|
||||
--h2-page-bg: var(--page-bg, #f4f2ef);
|
||||
--h2-surface: #faf9f7;
|
||||
--h2-surface-raised:#ffffff;
|
||||
--h2-surface-inset: #f0eeeb;
|
||||
|
||||
/* Foregrounds */
|
||||
--h2-fg: #1a1a1a;
|
||||
--h2-fg-secondary: #5c5650;
|
||||
--h2-muted: #8c857d;
|
||||
|
||||
/* Borders — two tiers for depth */
|
||||
--h2-border: #e5e0da;
|
||||
--h2-border-strong: #d5cfc8;
|
||||
|
||||
/* Semantic — assertive */
|
||||
--h2-success: #0f7b5f;
|
||||
--h2-warning: #c27b1a;
|
||||
--h2-error: #c13838;
|
||||
--h2-info: #0d5c63;
|
||||
|
||||
background: var(--h2-page-bg);
|
||||
}
|
||||
|
||||
/* ---- Card system ---- */
|
||||
.h2-card {
|
||||
background: var(--h2-surface-raised);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--h2-border);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.02);
|
||||
}
|
||||
.h2-card-flush {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Status rail motif (left-edge accent strip) ---- */
|
||||
.h2-rail-accent { border-left: 3px solid var(--h2-accent); }
|
||||
.h2-rail-success { border-left: 3px solid var(--h2-success); }
|
||||
.h2-rail-warning { border-left: 3px solid var(--h2-warning); }
|
||||
.h2-rail-error { border-left: 3px solid var(--h2-error); }
|
||||
|
||||
/* ---- Section headers ---- */
|
||||
.h2-section-header { }
|
||||
.h2-section-title {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--h2-fg);
|
||||
}
|
||||
.h2-section-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--h2-muted);
|
||||
}
|
||||
|
||||
/* ---- Card headers (icon + title) ---- */
|
||||
.h2-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--h2-border);
|
||||
}
|
||||
.h2-icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--h2-accent) 10%, transparent);
|
||||
color: var(--h2-accent);
|
||||
}
|
||||
.h2-icon-box-error {
|
||||
background: color-mix(in srgb, var(--h2-error) 10%, transparent);
|
||||
color: var(--h2-error);
|
||||
}
|
||||
|
||||
/* ---- Lists ---- */
|
||||
.h2-list {
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
.h2-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0;
|
||||
border-bottom: 1px solid var(--h2-border);
|
||||
}
|
||||
.h2-list-item:last-child { border-bottom: 0; padding-bottom: 1rem; }
|
||||
.h2-list-item:first-child { padding-top: 0.75rem; }
|
||||
|
||||
/* ---- Alert rows ---- */
|
||||
.h2-alert-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.75rem 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
/* ---- Ops cell borders ---- */
|
||||
.h2-cell-border {
|
||||
border-right: 1px solid var(--h2-border);
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.h2-cell-border:nth-child(2n) { border-right: none; }
|
||||
.h2-cell-border { border-bottom: 1px solid var(--h2-border); }
|
||||
}
|
||||
|
||||
/* ---- Ops status background tints ---- */
|
||||
.h2-ops-on-track { background: color-mix(in srgb, var(--h2-success) 3%, transparent); }
|
||||
.h2-ops-attention { background: color-mix(in srgb, var(--h2-accent) 3%, transparent); }
|
||||
.h2-ops-warning { background: color-mix(in srgb, var(--h2-warning) 3%, transparent); }
|
||||
.h2-ops-neutral { background: transparent; }
|
||||
|
||||
/* ---- Tone classes ---- */
|
||||
.h2-tone-pos { color: var(--h2-success); }
|
||||
.h2-tone-neg { color: var(--h2-error); }
|
||||
.h2-tone-neutral { color: var(--h2-muted); }
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.h2-badge-success {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--h2-success) 10%, transparent);
|
||||
color: var(--h2-success);
|
||||
border: 1px solid color-mix(in srgb, var(--h2-success) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.h2-btn-primary {
|
||||
background: var(--h2-accent) !important;
|
||||
color: #fff !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 1px 3px rgba(13,92,99,0.25), 0 1px 2px rgba(13,92,99,0.15) !important;
|
||||
transition: background 0.15s, box-shadow 0.15s !important;
|
||||
}
|
||||
.h2-btn-primary:hover {
|
||||
background: var(--h2-accent-hover) !important;
|
||||
box-shadow: 0 2px 6px rgba(13,92,99,0.3), 0 1px 3px rgba(13,92,99,0.2) !important;
|
||||
}
|
||||
.h2-btn-outline {
|
||||
border-radius: 10px !important;
|
||||
border-color: var(--h2-border-strong) !important;
|
||||
color: var(--h2-fg) !important;
|
||||
}
|
||||
|
||||
/* ---- Accent icon tile ---- */
|
||||
.h2-accent-icon {
|
||||
background: rgba(13, 92, 99, 0.08);
|
||||
color: #0d5c63;
|
||||
border: 1px solid rgba(13, 92, 99, 0.15);
|
||||
}
|
||||
|
||||
/* ---- Accent text ---- */
|
||||
.h2-text-accent { color: #0d5c63; }
|
||||
.h2-text-accent-hover { color: #0a4a50; }
|
||||
|
||||
/* ---- Card hover state ---- */
|
||||
.h2-card-hover:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||
border-color: rgba(13, 92, 99, 0.25);
|
||||
}
|
||||
</style>
|
||||
3364
app/pages/index.vue
3364
app/pages/index.vue
File diff suppressed because it is too large
Load Diff
14
app/pages/onboarding/active-leads/new.vue
Normal file
14
app/pages/onboarding/active-leads/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('New Active Lead')
|
||||
</script>
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="mt-0.5 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Active Lead</h1>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)] px-8 py-12 text-center shadow-sm">
|
||||
<p class="text-sm text-[var(--text-muted)] opacity-70">Active lead entry form coming online.</p>
|
||||
<NuxtLink to="/onboarding" class="mt-4 inline-block text-[12px] font-medium text-[var(--brand)] hover:text-[var(--brand)]">← Sales Pipeline</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
364
app/pages/onboarding/emissions/index.vue
Normal file
364
app/pages/onboarding/emissions/index.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Emissions review')
|
||||
|
||||
const toast = useToast()
|
||||
const { items, approve, sendToInsurer, markInForce } = useEmissionsQueue()
|
||||
|
||||
const pending = computed(() => items.value.filter((x) => x.status === 'pending_review'))
|
||||
const rest = computed(() => items.value.filter((x) => x.status !== 'pending_review'))
|
||||
|
||||
function onApprove(id: string) {
|
||||
approve(id)
|
||||
toast.add({ title: 'Marked approved', color: 'success' })
|
||||
}
|
||||
|
||||
function onSend(id: string) {
|
||||
sendToInsurer(id)
|
||||
toast.add({ title: 'Marked sent to insurer', color: 'success' })
|
||||
}
|
||||
|
||||
function onInForce(id: string) {
|
||||
markInForce(id)
|
||||
toast.add({ title: 'Marked in force', color: 'success' })
|
||||
}
|
||||
|
||||
/* ── Mock pipeline data for when queue is empty ── */
|
||||
const mockEmissions = [
|
||||
{ id: 'EM-2025-0041', customer: 'María Elena Pérez', insurer: 'ASSA', line: 'Auto', product: '2023 Toyota RAV4 — Comprehensive', premium: '$1,840', status: 'pending_review' as const, submitted: '2025-04-03', agent: 'Ana R.', docs: 3, docsTotal: 3 },
|
||||
{ id: 'EM-2025-0040', customer: 'Roberto Jiménez Mora', insurer: 'Pan-American Life', line: 'Life', product: 'Whole life — $150K', premium: '$1,440', status: 'approved' as const, submitted: '2025-04-02', agent: 'Ana R.', docs: 4, docsTotal: 4 },
|
||||
{ id: 'EM-2025-0039', customer: 'Luis Andrés Solís', insurer: 'Blue Cross', line: 'Health', product: 'Family health — Platinum', premium: '$8,400', status: 'sent_to_insurer' as const, submitted: '2025-04-01', agent: 'Ana R.', docs: 5, docsTotal: 5 },
|
||||
{ id: 'EM-2025-0038', customer: 'Sofía Campos Rojas', insurer: 'INS', line: 'Auto', product: '2024 Mazda CX-30 — Comprehensive', premium: '$1,380', status: 'pending_review' as const, submitted: '2025-03-30', agent: 'Marco V.', docs: 2, docsTotal: 3 },
|
||||
{ id: 'EM-2025-0037', customer: 'Carolina Fallas Vargas', insurer: 'ASSA', line: 'Renter', product: "Renter's insurance — Paraíso apt", premium: '$320', status: 'in_force' as const, submitted: '2025-03-28', agent: 'Marco V.', docs: 2, docsTotal: 2 },
|
||||
{ id: 'EM-2025-0036', customer: 'Roberto Jiménez Mora', insurer: 'ASSA', line: 'Home', product: 'Homeowner — Belén residence', premium: '$890', status: 'in_force' as const, submitted: '2025-03-25', agent: 'Ana R.', docs: 4, docsTotal: 4 },
|
||||
]
|
||||
|
||||
type EmissionStatus = 'pending_review' | 'approved' | 'sent_to_insurer' | 'in_force'
|
||||
|
||||
const statusMeta: Record<EmissionStatus, { label: string; class: string; icon: string }> = {
|
||||
pending_review: { label: 'Pending review', class: 'em-status-pending', icon: 'i-heroicons-clock' },
|
||||
approved: { label: 'Approved', class: 'em-status-approved', icon: 'i-heroicons-check' },
|
||||
sent_to_insurer: { label: 'Sent to insurer', class: 'em-status-sent', icon: 'i-heroicons-paper-airplane' },
|
||||
in_force: { label: 'In force', class: 'em-status-force', icon: 'i-heroicons-shield-check' },
|
||||
}
|
||||
|
||||
const activeFilter = ref<EmissionStatus | 'all'>('all')
|
||||
const filterTabs: { id: EmissionStatus | 'all'; label: string; count: number }[] = [
|
||||
{ id: 'all', label: 'All', count: mockEmissions.length },
|
||||
{ id: 'pending_review', label: 'Pending', count: mockEmissions.filter(e => e.status === 'pending_review').length },
|
||||
{ id: 'approved', label: 'Approved', count: mockEmissions.filter(e => e.status === 'approved').length },
|
||||
{ id: 'sent_to_insurer', label: 'Sent', count: mockEmissions.filter(e => e.status === 'sent_to_insurer').length },
|
||||
{ id: 'in_force', label: 'In force', count: mockEmissions.filter(e => e.status === 'in_force').length },
|
||||
]
|
||||
|
||||
const filteredEmissions = computed(() => {
|
||||
if (activeFilter.value === 'all') return mockEmissions
|
||||
return mockEmissions.filter(e => e.status === activeFilter.value)
|
||||
})
|
||||
|
||||
/* ── KPI summary ── */
|
||||
const kpis = [
|
||||
{ label: 'Pending review', value: mockEmissions.filter(e => e.status === 'pending_review').length.toString(), sub: 'Awaiting QA', dot: 'background: #c27b1a' },
|
||||
{ label: 'Approved', value: mockEmissions.filter(e => e.status === 'approved').length.toString(), sub: 'Ready to send', dot: 'background: #01696f' },
|
||||
{ label: 'Sent to insurer', value: mockEmissions.filter(e => e.status === 'sent_to_insurer').length.toString(), sub: 'Awaiting response', dot: 'background: #7c3aed' },
|
||||
{ label: 'In force', value: mockEmissions.filter(e => e.status === 'in_force').length.toString(), sub: 'This month', dot: 'background: #0f7b5f' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="em mx-auto max-w-5xl space-y-6 pb-12">
|
||||
<!-- Back -->
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Pipeline</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Sales flow indicator -->
|
||||
<SalesFlowIndicator current-stage="emission" />
|
||||
|
||||
<!-- Header -->
|
||||
<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)]">Emissions Review</h1>
|
||||
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
|
||||
Completed intakes land here for brokerage QA before submission to the carrier.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/onboarding/solicitud">
|
||||
<UButton size="sm" color="primary" icon="i-heroicons-plus">New solicitud</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- KPI Strip -->
|
||||
<div class="em-kpi-strip">
|
||||
<div v-for="(kpi, i) in kpis" :key="kpi.label" class="em-kpi">
|
||||
<p class="em-kpi-label">{{ kpi.label }}</p>
|
||||
<p class="em-kpi-value">{{ kpi.value }}</p>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span class="em-kpi-dot" :style="kpi.dot" />
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ kpi.sub }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="em-tabs">
|
||||
<button
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="em-tab"
|
||||
:class="activeFilter === tab.id ? 'em-tab-on' : 'em-tab-off'"
|
||||
@click="activeFilter = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="em-tab-count" :class="activeFilter === tab.id ? 'em-tab-count-on' : ''">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emissions table -->
|
||||
<div class="em-card">
|
||||
<div class="em-card-head">
|
||||
<UIcon name="i-heroicons-document-check" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Emissions queue</span>
|
||||
<span class="ml-auto text-[11px] text-[var(--text-muted)]">{{ filteredEmissions.length }} items</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredEmissions.length === 0" class="px-6 py-12 text-center">
|
||||
<UIcon name="i-heroicons-inbox-stack" style="width: 40px; height: 40px; color: #c0c0bc; margin: 0 auto 12px;" />
|
||||
<p class="text-[13px] text-[var(--text-muted)]">No emissions in this status</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="em-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Insurer</th>
|
||||
<th>Product</th>
|
||||
<th>Premium</th>
|
||||
<th>Docs</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="em in filteredEmissions" :key="em.id">
|
||||
<td class="font-mono text-[11px] font-medium">{{ em.id }}</td>
|
||||
<td>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">{{ em.customer }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ em.agent }}</p>
|
||||
</td>
|
||||
<td class="text-[13px]">{{ em.insurer }}</td>
|
||||
<td>
|
||||
<p class="text-[13px] text-[var(--text-primary)]">{{ em.line }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)] max-w-[200px] truncate">{{ em.product }}</p>
|
||||
</td>
|
||||
<td class="text-[13px] font-medium tabular-nums">{{ em.premium }}</td>
|
||||
<td>
|
||||
<span class="em-doc-badge" :class="em.docs === em.docsTotal ? 'em-doc-complete' : 'em-doc-partial'">
|
||||
{{ em.docs }}/{{ em.docsTotal }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="statusMeta[em.status].class">
|
||||
{{ statusMeta[em.status].label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-[12px] tabular-nums text-[var(--text-muted)]">{{ em.submitted }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button v-if="em.status === 'pending_review'" type="button" class="em-action-btn em-action-approve" title="Approve">
|
||||
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="em.status === 'approved'" type="button" class="em-action-btn em-action-send" title="Send to insurer">
|
||||
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="em.status === 'sent_to_insurer'" type="button" class="em-action-btn em-action-force" title="Mark in force">
|
||||
<UIcon name="i-heroicons-shield-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button type="button" class="em-action-btn" title="View details">
|
||||
<UIcon name="i-heroicons-eye" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Queue from composable (if any real items exist) -->
|
||||
<div v-if="items.length > 0" class="em-card">
|
||||
<div class="em-card-head">
|
||||
<UIcon name="i-heroicons-queue-list" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Live queue (from solicitud intake)</span>
|
||||
</div>
|
||||
<table class="em-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Customer</th>
|
||||
<th>Insurer</th>
|
||||
<th>Sub-ramo</th>
|
||||
<th>Line</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td class="font-mono text-[11px]">{{ row.createdAt.slice(0, 10) }}</td>
|
||||
<td class="text-[13px]">{{ row.customerLabel }}</td>
|
||||
<td class="text-[13px]">{{ row.insurerSlug }}</td>
|
||||
<td class="text-[13px]">{{ row.subRamoKey }}</td>
|
||||
<td class="text-[13px]">{{ row.productLine }}</td>
|
||||
<td>
|
||||
<span :class="statusMeta[row.status as EmissionStatus]?.class ?? 'em-status-pending'">
|
||||
{{ statusMeta[row.status as EmissionStatus]?.label ?? row.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button v-if="row.status === 'pending_review'" type="button" class="em-action-btn em-action-approve" @click="onApprove(row.id)">
|
||||
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="row.status === 'approved'" type="button" class="em-action-btn em-action-send" @click="onSend(row.id)">
|
||||
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="row.status === 'sent_to_insurer'" type="button" class="em-action-btn em-action-force" @click="onInForce(row.id)">
|
||||
<UIcon name="i-heroicons-shield-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.em-section-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: #8a8a86; margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.em-kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(4, 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;
|
||||
}
|
||||
.em-kpi {
|
||||
padding: 16px 20px; background: #ffffff;
|
||||
}
|
||||
.em-kpi:first-child { border-radius: 12px 0 0 12px; }
|
||||
.em-kpi:last-child { border-radius: 0 12px 12px 0; }
|
||||
.em-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.em-kpi-value {
|
||||
margin-top: 4px; font-size: 22px; font-weight: 600;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.em-kpi-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.em-kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.em-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.em-tab {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 6px 14px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
border: none; cursor: pointer; transition: all 150ms ease;
|
||||
}
|
||||
.em-tab-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.em-tab-off { background: transparent; color: var(--text-muted); }
|
||||
.em-tab-off:hover { color: var(--text-primary); }
|
||||
.em-tab-count {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 5px;
|
||||
border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted);
|
||||
}
|
||||
.em-tab-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
|
||||
/* ── Card ── */
|
||||
.em-card {
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.em-card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.em-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
.em-table th {
|
||||
text-align: left; padding: 10px 16px;
|
||||
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);
|
||||
}
|
||||
.em-table td {
|
||||
padding: 12px 16px; color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: top;
|
||||
}
|
||||
.em-table tr:last-child td { border-bottom: none; }
|
||||
.em-table tr:hover td { background: rgba(0,0,0,0.015); }
|
||||
|
||||
/* ── Status badges ── */
|
||||
.em-status-pending {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(194,123,26,0.08); color: #964219;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-approved {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(1,105,111,0.08); color: #01696f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-sent {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(124,58,237,0.08); color: #7c3aed;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-force {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(15,123,95,0.08); color: #0f7b5f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Doc badge ── */
|
||||
.em-doc-badge {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.em-doc-complete { background: rgba(15,123,95,0.08); color: #0f7b5f; }
|
||||
.em-doc-partial { background: rgba(194,123,26,0.08); color: #964219; }
|
||||
|
||||
/* ── Action buttons ── */
|
||||
.em-action-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
border: none; cursor: pointer;
|
||||
background: rgba(0,0,0,0.03); color: #8a8a86;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.em-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
||||
.em-action-approve:hover { background: rgba(15,123,95,0.1); color: #0f7b5f; }
|
||||
.em-action-send:hover { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
.em-action-force:hover { background: rgba(15,123,95,0.1); color: #0f7b5f; }
|
||||
</style>
|
||||
251
app/pages/onboarding/index.vue
Normal file
251
app/pages/onboarding/index.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Sales Pipeline')
|
||||
|
||||
type Stage = 'Lead' | 'Qualified' | 'Proposal' | 'Negotiation' | 'Won'
|
||||
|
||||
interface Deal {
|
||||
id: string
|
||||
customer: string
|
||||
product: string
|
||||
line: 'Auto' | 'Health' | 'Life' | 'General Risk' | 'Custom'
|
||||
premium: number
|
||||
agent: string
|
||||
stage: Stage
|
||||
daysInStage: number
|
||||
urgent: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
const deals: Deal[] = [
|
||||
{ id: 'D-001', customer: 'María Pérez', product: 'Auto Individual', line: 'Auto', premium: 1200, agent: 'Ana R.', stage: 'Lead', daysInStage: 1, urgent: false },
|
||||
{ id: 'D-002', customer: 'Empresa ABC S.A.', product: 'Fleet Auto', line: 'Auto', premium: 18400, agent: 'Carlos M.', stage: 'Lead', daysInStage: 3, urgent: false },
|
||||
{ id: 'D-003', customer: 'Jorge Herrera', product: 'Vida Individual', line: 'Life', premium: 3200, agent: 'Ana R.', stage: 'Lead', daysInStage: 0, urgent: false, note: 'Referral from client D-051' },
|
||||
{ id: 'D-004', customer: 'Clínica San José', product: 'Salud Grupal', line: 'Health', premium: 42000, agent: 'Luis F.', stage: 'Qualified', daysInStage: 5, urgent: false },
|
||||
{ id: 'D-005', customer: 'Carmen Ruiz', product: 'Salud Individual', line: 'Health', premium: 2800, agent: 'Ana R.', stage: 'Qualified', daysInStage: 2, urgent: false },
|
||||
{ id: 'D-006', customer: 'Constructora Delta', product: 'Todo Riesgo', line: 'General Risk', premium: 55000, agent: 'Carlos M.', stage: 'Qualified', daysInStage: 7, urgent: true, note: 'RFQ deadline Friday' },
|
||||
{ id: 'D-007', customer: 'Rodrigo Blanco', product: 'Vida + Accidentes', line: 'Life', premium: 4100, agent: 'Luis F.', stage: 'Qualified', daysInStage: 4, urgent: false },
|
||||
{ id: 'D-008', customer: 'Hotel Pacífico', product: 'Incendio y Robo', line: 'General Risk', premium: 28000, agent: 'Carlos M.', stage: 'Proposal', daysInStage: 6, urgent: false },
|
||||
{ id: 'D-009', customer: 'Supermercado Tico', product: 'Responsabilidad Civil', line: 'General Risk', premium: 9800, agent: 'Ana R.', stage: 'Proposal', daysInStage: 9, urgent: true, note: 'Follow up required today' },
|
||||
{ id: 'D-010', customer: 'Isabel Mora', product: 'Auto Individual', line: 'Auto', premium: 980, agent: 'Luis F.', stage: 'Proposal', daysInStage: 3, urgent: false },
|
||||
{ id: 'D-011', customer: 'Banco Regional', product: 'Colectivo Vida', line: 'Life', premium: 120000, agent: 'Carlos M.', stage: 'Negotiation', daysInStage: 14, urgent: true, note: 'Legal review pending' },
|
||||
{ id: 'D-012', customer: 'Farmacia Salud', product: 'Todo Riesgo', line: 'General Risk', premium: 17500, agent: 'Luis F.', stage: 'Negotiation', daysInStage: 8, urgent: false },
|
||||
{ id: 'D-013', customer: 'Andrea Cascante', product: 'Salud Individual', line: 'Health', premium: 2200, agent: 'Ana R.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
{ id: 'D-014', customer: 'Transportes del Sur', product: 'Fleet Auto', line: 'Auto', premium: 24000, agent: 'Carlos M.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
{ id: 'D-015', customer: 'Manuel Torres', product: 'Vida Individual', line: 'Life', premium: 5600, agent: 'Luis F.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
const filterLine = ref<string>('all')
|
||||
const filterAgent = ref<string>('all')
|
||||
|
||||
const lineOptions = [
|
||||
{ label: 'All Lines', value: 'all' },
|
||||
{ label: 'Auto', value: 'Auto' },
|
||||
{ label: 'Health', value: 'Health' },
|
||||
{ label: 'Life', value: 'Life' },
|
||||
{ label: 'General Risk', value: 'General Risk' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
]
|
||||
|
||||
const agentOptions = [
|
||||
{ label: 'All Agents', value: 'all' },
|
||||
{ label: 'Ana R.', value: 'Ana R.' },
|
||||
{ label: 'Carlos M.', value: 'Carlos M.' },
|
||||
{ label: 'Luis F.', value: 'Luis F.' },
|
||||
]
|
||||
|
||||
const stages: Stage[] = ['Lead', 'Qualified', 'Proposal', 'Negotiation', 'Won']
|
||||
|
||||
const stageLabel: Record<Stage, string> = {
|
||||
Lead: 'Lead',
|
||||
Qualified: 'Data Collection',
|
||||
Proposal: 'Quotation',
|
||||
Negotiation: 'Solicitud',
|
||||
Won: 'Emision',
|
||||
}
|
||||
|
||||
const stageConfig: Record<Stage, { color: string; dot: string; headerBg: string }> = {
|
||||
Lead: { color: 'text-[var(--text-muted)]', dot: 'bg-[var(--text-muted)]', headerBg: 'bg-[var(--surface)] border-[var(--card-border)]' },
|
||||
Qualified: { color: 'text-[var(--brand)]', dot: 'bg-[var(--brand)]', headerBg: 'bg-[var(--brand-faint)] border-[var(--brand-soft)]' },
|
||||
Proposal: { color: 'text-violet-700', dot: 'bg-violet-400', headerBg: 'bg-violet-50 border-violet-200' },
|
||||
Negotiation: { color: 'text-amber-700', dot: 'bg-amber-400', headerBg: 'bg-amber-50 border-amber-200' },
|
||||
Won: { color: 'text-emerald-700', dot: 'bg-emerald-500', headerBg: 'bg-emerald-50 border-emerald-200' },
|
||||
}
|
||||
|
||||
const lineColors: Record<string, string> = {
|
||||
Auto: 'bg-[var(--brand-soft)] text-[var(--brand)]',
|
||||
Health: 'bg-emerald-100 text-emerald-700',
|
||||
Life: 'bg-violet-100 text-violet-700',
|
||||
'General Risk': 'bg-amber-100 text-amber-700',
|
||||
Custom: 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]',
|
||||
}
|
||||
|
||||
const filteredDeals = computed(() => {
|
||||
return deals.filter(d => {
|
||||
const q = search.value.toLowerCase()
|
||||
const matchSearch = !q || d.customer.toLowerCase().includes(q) || d.product.toLowerCase().includes(q) || d.id.toLowerCase().includes(q)
|
||||
const matchLine = filterLine.value === 'all' || d.line === filterLine.value
|
||||
const matchAgent = filterAgent.value === 'all' || d.agent === filterAgent.value
|
||||
return matchSearch && matchLine && matchAgent
|
||||
})
|
||||
})
|
||||
|
||||
function stageDeals(stage: Stage) {
|
||||
return filteredDeals.value.filter(d => d.stage === stage)
|
||||
}
|
||||
|
||||
function stageTotal(stage: Stage) {
|
||||
return stageDeals(stage).reduce((s, d) => s + d.premium, 0)
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return n >= 1000 ? `$${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k` : `$${n}`
|
||||
}
|
||||
|
||||
function daysLabel(n: number) {
|
||||
if (n === 0) return 'Today'
|
||||
return `${n}d`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Sales Pipeline</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">{{ filteredDeals.length }} opportunities · {{ fmt(filteredDeals.reduce((s, d) => s + d.premium, 0)) }} total premium</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/onboarding/solicitud">
|
||||
<UButton size="sm" color="primary" icon="i-heroicons-plus">New Solicitud</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead">
|
||||
<UButton size="sm" color="primary" variant="soft" icon="i-heroicons-user-plus">Quick Lead</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--card-border)] bg-[var(--surface)] px-4 py-3 shadow-sm ring-1 ring-[var(--surface)]">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search customer, product, ID…"
|
||||
size="sm"
|
||||
class="w-64"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterLine"
|
||||
:items="lineOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterAgent"
|
||||
:items="agentOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
||||
<span
|
||||
v-for="stage in stages"
|
||||
:key="stage"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[stage].dot" />
|
||||
<span class="text-[11px]">{{ stageLabel[stage] }}</span>
|
||||
<span class="font-semibold">{{ stageDeals(stage).length }}</span>
|
||||
<span class="text-[var(--text-muted)] opacity-50 last:hidden">·</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<div
|
||||
v-for="stage in stages"
|
||||
:key="stage"
|
||||
class="flex w-[230px] shrink-0 flex-col gap-2"
|
||||
>
|
||||
<!-- Column header -->
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border px-3 py-2"
|
||||
:class="stageConfig[stage].headerBg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[stage].dot" />
|
||||
<span class="text-[13px] font-semibold" :class="stageConfig[stage].color">{{ stageLabel[stage] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{{ fmt(stageTotal(stage)) }}</span>
|
||||
<span class="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[var(--surface)]/70 px-1.5 text-[11px] font-semibold text-[var(--text-muted)] ring-1 ring-[var(--card-border)]/60">
|
||||
{{ stageDeals(stage).length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="stageDeals(stage).length === 0"
|
||||
class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)]/50 px-3 py-8 text-center"
|
||||
>
|
||||
<p class="text-[12px] text-[var(--text-muted)] opacity-70">No deals in this stage</p>
|
||||
</div>
|
||||
|
||||
<!-- Deal cards -->
|
||||
<div
|
||||
v-for="deal in stageDeals(stage)"
|
||||
:key="deal.id"
|
||||
class="group rounded-xl border px-3.5 py-3 shadow-sm ring-1 transition hover:shadow-md"
|
||||
:class="deal.urgent
|
||||
? 'border-rose-300 bg-rose-50/50 ring-rose-100 hover:border-rose-400'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] ring-[var(--surface)] hover:border-[var(--brand)]/30'"
|
||||
>
|
||||
<!-- Customer + product line badge -->
|
||||
<div class="flex items-start justify-between gap-1">
|
||||
<p class="text-[13px] font-semibold leading-snug text-[var(--text-primary)]">{{ deal.customer }}</p>
|
||||
<span
|
||||
class="ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="lineColors[deal.line]"
|
||||
>{{ deal.line }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">{{ deal.product }}</p>
|
||||
|
||||
<!-- Note -->
|
||||
<p v-if="deal.note" class="mt-1.5 text-[11px] italic text-amber-600">{{ deal.note }}</p>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-[var(--divider)] pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Urgent flag -->
|
||||
<span
|
||||
v-if="deal.urgent"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-rose-600"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
Urgent
|
||||
</span>
|
||||
<span class="text-[12px] font-bold text-[var(--text-primary)]">{{ fmt(deal.premium) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-[var(--text-muted)] opacity-70">
|
||||
<span>{{ deal.agent }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ daysLabel(deal.daysInStage) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 rounded-lg border border-[var(--card-border)]/60 bg-[var(--surface)] px-4 py-2.5 text-[11px] text-[var(--text-muted)] shadow-sm">
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase text-rose-600"><span class="h-1.5 w-1.5 rounded-full bg-rose-500" />Urgent</span> Action required</span>
|
||||
<span class="flex items-center gap-1.5"><span class="font-bold text-[var(--text-primary)]">$18k</span> Total premium at stage</span>
|
||||
<span>Days in stage shown on each card</span>
|
||||
<NuxtLink to="/onboarding/emissions" class="ml-auto font-medium text-[var(--brand)] hover:text-[var(--brand)]">Emissions queue →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
727
app/pages/onboarding/policy-upload/new.vue
Normal file
727
app/pages/onboarding/policy-upload/new.vue
Normal file
@@ -0,0 +1,727 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Nombramiento')
|
||||
|
||||
const intakeMode = ref<'scan' | 'manual'>('scan')
|
||||
const customerMode = ref<'existing' | 'new'>('existing')
|
||||
const customerSearch = ref('')
|
||||
|
||||
const uploadState = ref<'idle' | 'uploading' | 'processing' | 'review'>('idle')
|
||||
const fileName = ref('')
|
||||
const dragOver = ref(false)
|
||||
|
||||
/* ── Extracted policy data (mock — would come from AI model) ── */
|
||||
const extracted = reactive({
|
||||
policyNumber: '',
|
||||
carrier: '',
|
||||
lob: '',
|
||||
effectiveDate: '',
|
||||
expirationDate: '',
|
||||
premium: '',
|
||||
insuredName: '',
|
||||
insuredId: '',
|
||||
insuredEmail: '',
|
||||
insuredPhone: '',
|
||||
currentBroker: '',
|
||||
coverageSummary: '',
|
||||
customerMatch: null as null | 'existing' | 'new',
|
||||
matchedCustomerId: null as null | string,
|
||||
matchedCustomerName: null as null | string,
|
||||
confidence: 0,
|
||||
})
|
||||
|
||||
function simulateUpload(name: string) {
|
||||
fileName.value = name
|
||||
uploadState.value = 'uploading'
|
||||
|
||||
setTimeout(() => {
|
||||
uploadState.value = 'processing'
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate AI extraction results
|
||||
extracted.policyNumber = 'POL-2024-88412'
|
||||
extracted.carrier = 'ASSA Compania de Seguros'
|
||||
extracted.lob = 'Auto'
|
||||
extracted.effectiveDate = '2024-06-15'
|
||||
extracted.expirationDate = '2025-06-15'
|
||||
extracted.premium = '$1,840.00'
|
||||
extracted.insuredName = 'María Elena Pérez Solano'
|
||||
extracted.insuredId = '1-0456-0812'
|
||||
extracted.insuredEmail = 'maria.perez@email.com'
|
||||
extracted.insuredPhone = '+506 8834-2291'
|
||||
extracted.currentBroker = 'Seguros Internacionales S.A.'
|
||||
extracted.coverageSummary = 'Comprehensive auto coverage, $50K liability, $25K collision, roadside assistance included.'
|
||||
extracted.customerMatch = 'existing'
|
||||
extracted.matchedCustomerId = 'C-1042'
|
||||
extracted.matchedCustomerName = 'María Pérez'
|
||||
extracted.confidence = 94
|
||||
|
||||
uploadState.value = 'review'
|
||||
}, 2000)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) simulateUpload(file.name)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOver.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) simulateUpload(file.name)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
uploadState.value = 'idle'
|
||||
fileName.value = ''
|
||||
extracted.policyNumber = ''
|
||||
extracted.carrier = ''
|
||||
extracted.customerMatch = null
|
||||
extracted.confidence = 0
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function confirmTransfer() {
|
||||
toast.add({
|
||||
title: 'Nombramiento initiated',
|
||||
description: `Broker of record transfer started for ${extracted.policyNumber}. The customer profile has been updated.`,
|
||||
color: 'success'
|
||||
})
|
||||
reset()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<!-- Back + header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">
|
||||
Sales Pipeline
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Nombramiento</h1>
|
||||
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Register a policy and become the broker of record. Scan a document with AI or enter details manually, then link to an existing customer or create a new one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Intake mode toggle -->
|
||||
<div v-if="uploadState === 'idle'" class="flex flex-col gap-4">
|
||||
<div class="nom-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="nom-mode-btn"
|
||||
:class="intakeMode === 'scan' ? 'nom-mode-active' : 'nom-mode-inactive'"
|
||||
@click="intakeMode = 'scan'"
|
||||
>
|
||||
<UIcon name="i-heroicons-sparkles" style="width: 16px; height: 16px;" />
|
||||
AI scan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nom-mode-btn"
|
||||
:class="intakeMode === 'manual' ? 'nom-mode-active' : 'nom-mode-inactive'"
|
||||
@click="intakeMode = 'manual'"
|
||||
>
|
||||
<UIcon name="i-heroicons-pencil-square" style="width: 16px; height: 16px;" />
|
||||
Manual entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Customer association -->
|
||||
<div class="nom-customer-section">
|
||||
<p class="nom-label">Customer</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="nom-customer-btn"
|
||||
:class="customerMode === 'existing' ? 'nom-customer-active' : 'nom-customer-inactive'"
|
||||
@click="customerMode = 'existing'"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 16px; height: 16px;" />
|
||||
Existing customer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nom-customer-btn"
|
||||
:class="customerMode === 'new' ? 'nom-customer-active' : 'nom-customer-inactive'"
|
||||
@click="customerMode = 'new'"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-plus" style="width: 16px; height: 16px;" />
|
||||
New customer
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="customerMode === 'existing'" class="mt-3">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, ID, or email..."
|
||||
size="sm"
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<p class="mt-1.5 text-[11px] text-[var(--text-muted)]">Select the customer this policy belongs to. AI scan will also attempt automatic matching.</p>
|
||||
</div>
|
||||
<div v-else class="mt-3">
|
||||
<p class="text-[12px] text-[var(--text-muted)]">A new customer profile will be created from the policy details.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ AI SCAN PATH ═══ -->
|
||||
|
||||
<!-- Upload zone — idle state (scan mode) -->
|
||||
<div
|
||||
v-if="uploadState === 'idle' && intakeMode === 'scan'"
|
||||
class="nom-upload-zone"
|
||||
:class="{ 'nom-upload-zone-active': dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="nom-icon-ring">
|
||||
<UIcon name="i-heroicons-document-arrow-up" style="width: 24px; height: 24px;" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">Upload policy document</p>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Drop a PDF here, or click to browse. AI will read the policy and extract all fields.
|
||||
</p>
|
||||
</div>
|
||||
<label class="nom-browse-btn">
|
||||
Browse files
|
||||
<input type="file" accept=".pdf,.png,.jpg,.jpeg" class="sr-only" @change="onFileSelect" />
|
||||
</label>
|
||||
<p class="text-[11px] text-[var(--text-muted)] opacity-60">PDF, PNG, or JPG up to 25 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MANUAL ENTRY PATH ═══ -->
|
||||
<div v-if="uploadState === 'idle' && intakeMode === 'manual'" class="nom-data-card">
|
||||
<div class="nom-data-header">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Policy details</p>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Enter the policy information manually. All fields can be edited later.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Policy number</label>
|
||||
<UInput placeholder="e.g. POL-2024-00001" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Carrier</label>
|
||||
<UInput placeholder="Carrier name" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Line of business</label>
|
||||
<USelect :items="[{ label: 'Auto', value: 'auto' }, { label: 'Health', value: 'health' }, { label: 'Life', value: 'life' }, { label: 'General Risk', value: 'general-risk' }, { label: 'Other', value: 'other' }]" placeholder="Select..." size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Premium</label>
|
||||
<UInput placeholder="$0.00" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Effective date</label>
|
||||
<UInput size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Expiration date</label>
|
||||
<UInput size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Previous broker</label>
|
||||
<UInput placeholder="Outgoing brokerage (if any)" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="nom-data-grid" v-if="customerMode === 'new'">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Insured name</label>
|
||||
<UInput placeholder="Full legal name" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">ID number</label>
|
||||
<UInput placeholder="Cédula or passport" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Email</label>
|
||||
<UInput placeholder="email@example.com" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Phone</label>
|
||||
<UInput placeholder="+506 0000-0000" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-5 pb-2">
|
||||
<p class="text-[12px] text-[var(--text-muted)] italic">Customer details will be pulled from the selected existing profile.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<label class="nom-label">Coverage notes</label>
|
||||
<UTextarea placeholder="Optional — describe coverage, limits, deductibles..." size="sm" :rows="2" class="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual entry actions -->
|
||||
<div v-if="uploadState === 'idle' && intakeMode === 'manual'" class="flex flex-wrap items-center justify-end gap-2">
|
||||
<UButton color="neutral" variant="outline">Save as draft</UButton>
|
||||
<UButton color="primary">Register policy</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Uploading state -->
|
||||
<div v-else-if="uploadState === 'uploading'" class="nom-status-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-spinner" />
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">Uploading {{ fileName }}</p>
|
||||
<p class="mt-0.5 text-[13px] text-[var(--text-muted)]">Sending document to processing pipeline...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing state -->
|
||||
<div v-else-if="uploadState === 'processing'" class="nom-status-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-spinner" />
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">AI is reading the policy</p>
|
||||
<p class="mt-0.5 text-[13px] text-[var(--text-muted)]">Extracting insured details, coverage terms, carrier info, and matching against existing customers...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review state — extracted data -->
|
||||
<template v-else-if="uploadState === 'review'">
|
||||
<!-- Confidence bar -->
|
||||
<div class="nom-confidence-strip">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-sparkles" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span class="text-[13px] font-medium text-[var(--text-primary)]">AI extraction complete</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-confidence-bar-track">
|
||||
<div class="nom-confidence-bar-fill" :style="`width: ${extracted.confidence}%`" />
|
||||
</div>
|
||||
<span class="nom-confidence-badge">{{ extracted.confidence }}%</span>
|
||||
<span class="text-[10px] font-semibold uppercase" :style="extracted.confidence >= 90 ? 'color: #059669' : extracted.confidence >= 70 ? 'color: #d97706' : 'color: #dc2626'">
|
||||
{{ extracted.confidence >= 90 ? 'High' : extracted.confidence >= 70 ? 'Medium' : 'Low' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer match -->
|
||||
<div v-if="extracted.customerMatch === 'existing'" class="nom-match-card nom-match-existing">
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 20px; height: 20px; flex-shrink: 0;" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Matched to existing customer: <strong>{{ extracted.matchedCustomerName }}</strong>
|
||||
<span class="text-[var(--text-muted)]"> ({{ extracted.matchedCustomerId }})</span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">This policy will be added to their existing profile.</p>
|
||||
</div>
|
||||
<UButton size="xs" color="neutral" variant="soft">Change</UButton>
|
||||
</div>
|
||||
<div v-else-if="extracted.customerMatch === 'new'" class="nom-match-card nom-match-new">
|
||||
<UIcon name="i-heroicons-user-plus" style="width: 20px; height: 20px; flex-shrink: 0;" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">New customer will be created</p>
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">No matching customer found. A new profile will be set up from the extracted data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extracted fields -->
|
||||
<div class="nom-data-card">
|
||||
<div class="nom-data-header">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Policy details</p>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Review and correct any fields before initiating the transfer.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Policy number</label>
|
||||
<UInput :model-value="extracted.policyNumber" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Carrier</label>
|
||||
<UInput :model-value="extracted.carrier" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Line of business</label>
|
||||
<UInput :model-value="extracted.lob" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Premium</label>
|
||||
<UInput :model-value="extracted.premium" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Effective date</label>
|
||||
<UInput :model-value="extracted.effectiveDate" size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Expiration date</label>
|
||||
<UInput :model-value="extracted.expirationDate" size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Current broker</label>
|
||||
<UInput :model-value="extracted.currentBroker" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Insured name</label>
|
||||
<UInput :model-value="extracted.insuredName" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">ID number</label>
|
||||
<UInput :model-value="extracted.insuredId" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Email</label>
|
||||
<UInput :model-value="extracted.insuredEmail" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Phone</label>
|
||||
<UInput :model-value="extracted.insuredPhone" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<label class="nom-label">Coverage summary</label>
|
||||
<UTextarea :model-value="extracted.coverageSummary" size="sm" :rows="2" class="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton color="neutral" variant="soft" @click="reset">
|
||||
Start over
|
||||
</UButton>
|
||||
<div class="flex gap-2">
|
||||
<UButton color="neutral" variant="outline">
|
||||
Save as draft
|
||||
</UButton>
|
||||
<UButton color="primary" @click="confirmTransfer">
|
||||
Initiate transfer
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- How it works -->
|
||||
<div v-if="uploadState === 'idle'" class="nom-info-section">
|
||||
<p class="text-[13px] font-semibold text-[var(--text-primary)]">How it works</p>
|
||||
<ol class="nom-steps">
|
||||
<li>
|
||||
<span class="nom-step-num">1</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Upload the policy</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Drop a PDF or image of the policy from the outgoing brokerage.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">2</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">AI extracts the data</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Policy number, carrier, coverage, insured details, and dates are read automatically.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">3</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Customer matching</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">The system checks if the insured is an existing customer or creates a new profile.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">4</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Review and transfer</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Verify the extracted fields, then initiate the broker of record change.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Mode toggle ── */
|
||||
.nom-mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.nom-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.nom-mode-active {
|
||||
background: #ffffff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.nom-mode-inactive {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nom-mode-inactive:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Customer association ── */
|
||||
.nom-customer-section {
|
||||
padding: 16px 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
.nom-customer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.nom-customer-active {
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
border-color: rgba(1, 105, 111, 0.2);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-customer-inactive {
|
||||
background: transparent;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nom-customer-inactive:hover {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Upload drop zone ── */
|
||||
.nom-upload-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
padding: 40px 24px;
|
||||
border: 1.5px dashed rgba(0, 0, 0, 0.12);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nom-upload-zone:hover,
|
||||
.nom-upload-zone-active {
|
||||
border-color: #01696f;
|
||||
background: rgba(1, 105, 111, 0.02);
|
||||
}
|
||||
|
||||
.nom-icon-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
.nom-browse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #01696f;
|
||||
background: rgba(1, 105, 111, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.nom-browse-btn:hover {
|
||||
background: rgba(1, 105, 111, 0.14);
|
||||
}
|
||||
|
||||
/* ── Status card (uploading / processing) ── */
|
||||
.nom-status-card {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.nom-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(1, 105, 111, 0.15);
|
||||
border-top-color: #01696f;
|
||||
border-radius: 50%;
|
||||
animation: nom-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes nom-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Confidence strip ── */
|
||||
.nom-confidence-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(1, 105, 111, 0.04);
|
||||
border: 1px solid rgba(1, 105, 111, 0.1);
|
||||
}
|
||||
|
||||
.nom-confidence-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(1, 105, 111, 0.1);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-confidence-bar-track {
|
||||
width: 80px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nom-confidence-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: #01696f;
|
||||
transition: width 600ms ease;
|
||||
}
|
||||
|
||||
/* ── Customer match cards ── */
|
||||
.nom-match-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.nom-match-existing {
|
||||
background: rgba(1, 105, 111, 0.03);
|
||||
border-color: rgba(1, 105, 111, 0.1);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-match-new {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Data card ── */
|
||||
.nom-data-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nom-data-header {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nom-data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.nom-data-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.nom-data-divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 20px;
|
||||
}
|
||||
.nom-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.nom-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ── Info section ── */
|
||||
.nom-info-section {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
.nom-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.nom-steps li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.nom-step-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
color: #01696f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
||||
14
app/pages/onboarding/potential-leads/new.vue
Normal file
14
app/pages/onboarding/potential-leads/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('New Potential Lead')
|
||||
</script>
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="mt-0.5 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Potential Lead</h1>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)] px-8 py-12 text-center shadow-sm">
|
||||
<p class="text-sm text-[var(--text-muted)] opacity-70">Potential lead entry form coming online.</p>
|
||||
<NuxtLink to="/onboarding" class="mt-4 inline-block text-[12px] font-medium text-[var(--brand)] hover:text-[var(--brand)]">← Sales Pipeline</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
311
app/pages/onboarding/solicitud.vue
Normal file
311
app/pages/onboarding/solicitud.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormCatalogProductLine, FormCatalogSelection } from '~/types/form-catalog'
|
||||
import { useFormsCatalog } from '~/composables/useFormsCatalog'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Nueva solicitud')
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
/* ── 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])
|
||||
}
|
||||
|
||||
const {
|
||||
filterRows: resolveForms,
|
||||
insurerItems,
|
||||
subRamoItems,
|
||||
productLineItems,
|
||||
fieldGroupsForMatched
|
||||
} = useFormsCatalog()
|
||||
|
||||
const { profile, touch } = useCustomerProfileVault()
|
||||
const { enqueue } = useEmissionsQueue()
|
||||
|
||||
const insurerSlug = ref<string | null>(null)
|
||||
const subRamoKey = ref<string | null>(null)
|
||||
const personKind = ref<'natural' | 'juridica'>('natural')
|
||||
const productLine = ref<FormCatalogProductLine | 'any'>('any')
|
||||
|
||||
const subRamoOptions = computed(() => subRamoItems(insurerSlug.value))
|
||||
|
||||
watch(insurerSlug, () => {
|
||||
subRamoKey.value = null
|
||||
})
|
||||
|
||||
const bindToken = computed(() => {
|
||||
const b = route.query.bind
|
||||
return typeof b === 'string' ? b : null
|
||||
})
|
||||
|
||||
const selection = computed(
|
||||
(): FormCatalogSelection => ({
|
||||
insurerSlug: insurerSlug.value,
|
||||
subRamoKey: subRamoKey.value,
|
||||
personKind: personKind.value,
|
||||
productLine: productLine.value
|
||||
})
|
||||
)
|
||||
|
||||
const matchedForms = computed(() => resolveForms(selection.value))
|
||||
const fieldGroups = computed(() => fieldGroupsForMatched(matchedForms.value))
|
||||
|
||||
const personItems = [
|
||||
{ label: 'Natural', value: 'natural' as const },
|
||||
{ label: 'Jurídica', value: 'juridica' as const }
|
||||
]
|
||||
|
||||
async function copyLabel(label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(label)
|
||||
toast.add({ title: 'Copied', color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Could not copy', color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const draftSavedAt = ref<string | null>(null)
|
||||
|
||||
function saveProfileDraft() {
|
||||
touch()
|
||||
draftSavedAt.value = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
toast.add({ title: 'Profile draft saved locally', color: 'success' })
|
||||
}
|
||||
|
||||
function submitToEmissions() {
|
||||
if (!insurerSlug.value || !subRamoKey.value) {
|
||||
toast.add({ title: 'Select insurer and sub-ramo', color: 'error' })
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
customerLabel: profile.value.full_name || 'Customer',
|
||||
insurerSlug: insurerSlug.value,
|
||||
subRamoKey: subRamoKey.value,
|
||||
productLine: String(productLine.value),
|
||||
bindToken: bindToken.value ?? undefined
|
||||
})
|
||||
toast.add({ title: 'Added to emissions queue', color: 'success' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sol mx-auto max-w-5xl space-y-6 pb-12">
|
||||
<!-- Back -->
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Pipeline</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Sales flow indicator -->
|
||||
<SalesFlowIndicator current-stage="solicitud" />
|
||||
|
||||
<UAlert
|
||||
v-if="bindToken"
|
||||
color="info"
|
||||
variant="soft"
|
||||
title="Broker intake link"
|
||||
:description="`Bind token: ${bindToken}`"
|
||||
/>
|
||||
|
||||
<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)]">New Solicitud</h1>
|
||||
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
|
||||
Choose insurer, sub-ramo, person type, and product line. Required forms come from the
|
||||
<NuxtLink to="/settings/forms" class="text-[#01696f] hover:underline">forms library</NuxtLink>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline bar -->
|
||||
<div v-if="activeDeals.length > 0">
|
||||
<div v-if="!pipelineDeal" style="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);">
|
||||
<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" style="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;cursor:pointer;" @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>
|
||||
|
||||
<div class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-adjustments-horizontal" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Selection</span>
|
||||
</div>
|
||||
<div class="sol-card-body grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<UFormField label="Aseguradora" required>
|
||||
<USelect
|
||||
v-model="insurerSlug"
|
||||
:items="insurerItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select…"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Sub-ramo" required>
|
||||
<USelect
|
||||
v-model="subRamoKey"
|
||||
:items="subRamoOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Choose insurer first"
|
||||
:disabled="!insurerSlug"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Tipo de persona">
|
||||
<USelect v-model="personKind" :items="personItems" value-key="value" label-key="label" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Product line"
|
||||
description="Required for health (local/intl) and auto (full vs DAT). Use “Any” for generic rows only."
|
||||
>
|
||||
<USelect
|
||||
v-model="productLine"
|
||||
:items="productLineItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fieldGroups.length" class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-rectangle-group" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Field groups (review / autofill)</span>
|
||||
</div>
|
||||
<div class="sol-card-body space-y-6">
|
||||
<div v-for="g in fieldGroups" :key="g.id" class="rounded-lg border border-[rgba(0,0,0,0.06)] bg-[rgba(0,0,0,0.015)] p-4">
|
||||
<h3 class="text-[13px] font-semibold text-[var(--text-primary)]">{{ g.title }}</h3>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ g.description }}</p>
|
||||
<p class="mt-2 font-mono text-[10px] text-[var(--text-muted)] opacity-70">Keys: {{ g.fieldKeys.join(', ') }}</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Nombre completo (profile)">
|
||||
<UInput v-model="profile.full_name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Documento ID">
|
||||
<UInput v-model="profile.document_id" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Placa (auto)">
|
||||
<UInput v-model="profile.plate" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Valor declarado">
|
||||
<UInput v-model="profile.declared_value" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UButton color="neutral" variant="soft" size="sm" @click="saveProfileDraft">Save profile draft</UButton>
|
||||
<span v-if="draftSavedAt" class="text-[11px] text-emerald-600 font-medium">
|
||||
<UIcon name="i-heroicons-check-circle" style="width: 13px; height: 13px; vertical-align: -2px;" /> Saved at {{ draftSavedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-document-text" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Forms to complete</span>
|
||||
<span class="ml-auto text-[11px] font-medium text-[var(--text-muted)]">{{ matchedForms.length }} forms</span>
|
||||
</div>
|
||||
<div class="sol-card-body">
|
||||
<div v-if="!insurerSlug || !subRamoKey" class="text-[13px] text-[var(--text-muted)] py-2">
|
||||
Select insurer and sub-ramo to list required templates.
|
||||
</div>
|
||||
<div v-else-if="matchedForms.length === 0" class="text-[13px] text-amber-700 py-2">
|
||||
No rows match this combination. Try another product line.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="row in matchedForms"
|
||||
:key="row.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[rgba(0,0,0,0.06)] bg-[rgba(0,0,0,0.015)] px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-[13px] font-semibold text-[var(--text-primary)]">{{ row.id }}</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ row.description }}</p>
|
||||
<a
|
||||
:href="row.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1 inline-block break-all text-[12px] text-[#01696f] hover:underline"
|
||||
>
|
||||
{{ row.fileLabel }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
v-if="row.kind === 'identity'"
|
||||
icon="i-heroicons-document-duplicate"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Copy file name"
|
||||
@click="copyLabel(row.fileLabel)"
|
||||
/>
|
||||
<span v-if="row.badge != null" class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[rgba(1,105,111,0.08)] text-[#01696f]">{{ row.badge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="matchedForms.length" class="sol-card-footer">
|
||||
<NuxtLink to="/onboarding/emissions">
|
||||
<UButton color="neutral" variant="soft" size="sm">Open emissions queue</UButton>
|
||||
</NuxtLink>
|
||||
<UButton color="primary" size="sm" icon="i-heroicons-paper-airplane" @click="submitToEmissions">
|
||||
Send to emissions review
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sol-section-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: #8a8a86; margin-bottom: 4px;
|
||||
}
|
||||
.sol-card {
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sol-card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
.sol-card-body { padding: 20px; }
|
||||
.sol-card-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 14px 20px; border-top: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
</style>
|
||||
1769
app/pages/policies/[id].vue
Normal file
1769
app/pages/policies/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
836
app/pages/policies/book.vue
Normal 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>
|
||||
965
app/pages/policies/groups/index.vue
Normal file
965
app/pages/policies/groups/index.vue
Normal 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
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ const providerId = route.params.provider_id as string
|
||||
const toast = useToast()
|
||||
const { $providers } = useNuxtApp()
|
||||
|
||||
const { emails, roles, label } = useProviderContactEmails(providerId)
|
||||
|
||||
const { data, pending, error, refresh } = useProviders(`/providers/${providerId}`)
|
||||
const provider = computed(() => data.value?.data)
|
||||
|
||||
@@ -125,7 +127,7 @@ const clientTypeColor = (ct: string) =>
|
||||
{{ provider.active ? 'Active' : 'Inactive' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ provider.name }}</h1>
|
||||
<h1 class="text-2xl font-semibold text-[var(--text-primary)]">{{ provider.name }}</h1>
|
||||
<p class="text-gray-500 text-sm font-mono">{{ provider.provider_id }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -144,7 +146,7 @@ const clientTypeColor = (ct: string) =>
|
||||
<!-- Info -->
|
||||
<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" /> Provider Details
|
||||
</p>
|
||||
</template>
|
||||
@@ -157,11 +159,29 @@ const clientTypeColor = (ct: string) =>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-envelope" class="w-4 h-4" />
|
||||
Outbound email roles
|
||||
</p>
|
||||
</template>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
Stored in this browser for demo — sync to API later. Workflows (quotes, claims, renewals) resolve recipients
|
||||
from these slots.
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField v-for="role in roles" :key="role" :label="label(role)">
|
||||
<UInput v-model="emails[role]" type="email" placeholder="name@carrier.com" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Templates — grouped by policy_type → client_type -->
|
||||
<UCard>
|
||||
<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" class="w-4 h-4" /> Solicitation Templates
|
||||
</p>
|
||||
<UButton icon="i-heroicons-arrow-up-tray" color="primary" size="sm" @click="isUploadOpen = true">
|
||||
@@ -197,7 +217,7 @@ const clientTypeColor = (ct: string) =>
|
||||
v-for="t in tmplList"
|
||||
:key="t.template_id"
|
||||
class="flex items-center justify-between p-3 border rounded-lg text-sm"
|
||||
:class="t.active ? 'border-gray-200 bg-white' : 'border-gray-100 bg-gray-50 opacity-60'"
|
||||
:class="t.active ? 'border-gray-200 bg-[var(--surface)]' : 'border-gray-100 bg-gray-50 opacity-60'"
|
||||
>
|
||||
<div class="space-y-0.5 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@@ -246,7 +266,7 @@ const clientTypeColor = (ct: string) =>
|
||||
<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">Upload Template</h2>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Upload Template</h2>
|
||||
<p class="text-sm text-gray-500">Upload a fillable PDF solicitation form</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isUploadOpen = false" />
|
||||
|
||||
@@ -14,8 +14,8 @@ const providers = computed(() => {
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-900">Providers</h1>
|
||||
<p class="text-gray-500 text-sm">Insurance carrier management</p>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Providers</h1>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Insurance carrier management</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">{{ providers.length }} providers</UBadge>
|
||||
@@ -39,7 +39,7 @@ const providers = computed(() => {
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<p class="font-semibold text-slate-900 text-lg">{{ p.name }}</p>
|
||||
<p class="font-semibold text-[var(--text-primary)] text-lg">{{ p.name }}</p>
|
||||
<UBadge :color="p.active ? 'green' : 'red'" variant="soft" size="xs">
|
||||
{{ p.active ? 'Active' : 'Inactive' }}
|
||||
</UBadge>
|
||||
|
||||
@@ -31,14 +31,14 @@ async function submit() {
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back</UButton>
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-900">New Provider</h1>
|
||||
<p class="text-gray-500 text-sm">Register a new insurance carrier</p>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Provider</h1>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Register a new insurance carrier</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UCard class="max-w-2xl">
|
||||
<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" /> Provider Information
|
||||
</p>
|
||||
</template>
|
||||
|
||||
291
app/pages/quotes/auto/index.vue
Normal file
291
app/pages/quotes/auto/index.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import { emptyAutoQuoteDraft } from '~/composables/useAutoQuoteDraft'
|
||||
import type { AutoQuoteIntakePayload, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
/** Client-only: many Nuxt UI fields on this screen can stall hydration / main thread if SSR + client fight */
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · Auto')
|
||||
|
||||
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
solicit: 'Quotes to solicit',
|
||||
acceptance: 'Acceptance'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyAutoQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One package — we’ll email carriers’ quoting inboxes on file.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Personal auto.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Business or group.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
title: 'Fleet',
|
||||
hint: 'Fleet program.',
|
||||
icon: 'i-heroicons-truck'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromCustomer() {
|
||||
const c = draft.client
|
||||
if (!c.fullName.trim() || !c.email.trim()) {
|
||||
toast.add({
|
||||
title: 'Add legal name and email',
|
||||
description: 'We need them for carrier notifications.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!canProceedFromCustomer()) return false
|
||||
if (
|
||||
(draft.segment === 'corporate' || draft.segment === 'fleet') &&
|
||||
!draft.client.organizationName?.trim()
|
||||
) {
|
||||
toast.add({
|
||||
title: 'Add organization',
|
||||
description: 'Required for corporate and fleet policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one insurance company and one coverage package.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): AutoQuoteIntakePayload {
|
||||
return {
|
||||
quoteMode: draft.quoteMode!,
|
||||
segment: draft.segment!,
|
||||
client: { ...draft.client },
|
||||
vehicle: { ...draft.vehicle },
|
||||
solicit: {
|
||||
carrierIds: [...draft.solicit.carrierIds],
|
||||
planIds: [...draft.solicit.planIds]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'auto', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Auto quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Set up the risk, choose who to solicit, then accept — three steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<QuotesAutoSetupStep
|
||||
v-if="step === 'setup'"
|
||||
:draft="draft"
|
||||
:mode-cards="modeCards"
|
||||
:segment-cards="segmentCards"
|
||||
/>
|
||||
|
||||
<QuotesAutoSolicitQuotesStep
|
||||
v-else-if="step === 'solicit' && draft.quoteMode"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
/>
|
||||
|
||||
<QuotesAutoAcceptanceStep
|
||||
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
:segment="draft.segment"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton
|
||||
v-if="step !== 'setup'"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
@click="goPrev"
|
||||
>
|
||||
Back
|
||||
</UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext">
|
||||
Continue
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="intakeBusy"
|
||||
:disabled="intakeBusy"
|
||||
@click="finalize"
|
||||
>
|
||||
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user