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

1037 lines
36 KiB
Vue

<script setup lang="ts">
usePageTitle('Agents & Commissions · Settings')
/* ── Types ── */
type AgentStatus = 'active' | 'inactive' | 'suspended'
type CommissionTier = { lobId: string; lob: string; newPct: number; renewalPct: number }
interface Agent {
id: string
name: string
email: string
phone: string
role: string
status: AgentStatus
hireDate: string
book: { policies: number; gwp: number; collected: number; outstanding: number }
commissionTiers: CommissionTier[]
ytdEarned: number
ytdPending: number
lastLogin: string
}
/* ── Mock data ── */
const agents = ref<Agent[]>([
{
id: 'AG-001', name: 'Ana Ramírez', email: 'ana.ramirez@segur-os.com', phone: '+506 8812-4455',
role: 'Senior producer', status: 'active', hireDate: '2021-03-15',
book: { policies: 142, gwp: 2_840_000, collected: 2_610_000, outstanding: 230_000 },
commissionTiers: [
{ lobId: 'auto', lob: 'Auto', newPct: 15, renewalPct: 10 },
{ lobId: 'health', lob: 'Health', newPct: 12, renewalPct: 8 },
{ lobId: 'life', lob: 'Life', newPct: 18, renewalPct: 12 },
{ lobId: 'property', lob: 'Property', newPct: 14, renewalPct: 9 },
{ lobId: 'general', lob: 'General risk', newPct: 13, renewalPct: 8 },
],
ytdEarned: 48_200, ytdPending: 6_400, lastLogin: '2026-04-05 08:32'
},
{
id: 'AG-002', name: 'Marco Villanueva', email: 'marco.v@segur-os.com', phone: '+506 8899-2211',
role: 'Producer', status: 'active', hireDate: '2022-08-01',
book: { policies: 88, gwp: 1_620_000, collected: 1_480_000, outstanding: 140_000 },
commissionTiers: [
{ lobId: 'auto', lob: 'Auto', newPct: 14, renewalPct: 9 },
{ lobId: 'health', lob: 'Health', newPct: 11, renewalPct: 7 },
{ lobId: 'life', lob: 'Life', newPct: 16, renewalPct: 10 },
{ lobId: 'property', lob: 'Property', newPct: 12, renewalPct: 8 },
{ lobId: 'general', lob: 'General risk', newPct: 12, renewalPct: 7 },
],
ytdEarned: 28_600, ytdPending: 3_200, lastLogin: '2026-04-04 17:15'
},
{
id: 'AG-003', name: 'Lucía Fernández', email: 'lucia.f@segur-os.com', phone: '+506 7745-3388',
role: 'Junior producer', status: 'active', hireDate: '2024-01-10',
book: { policies: 34, gwp: 420_000, collected: 385_000, outstanding: 35_000 },
commissionTiers: [
{ lobId: 'auto', lob: 'Auto', newPct: 12, renewalPct: 8 },
{ lobId: 'health', lob: 'Health', newPct: 10, renewalPct: 6 },
{ lobId: 'life', lob: 'Life', newPct: 14, renewalPct: 9 },
{ lobId: 'property', lob: 'Property', newPct: 11, renewalPct: 7 },
{ lobId: 'general', lob: 'General risk', newPct: 10, renewalPct: 6 },
],
ytdEarned: 8_400, ytdPending: 1_100, lastLogin: '2026-04-05 09:01'
},
{
id: 'AG-004', name: 'Diego Mora', email: 'diego.m@segur-os.com', phone: '+506 6612-9944',
role: 'Producer', status: 'suspended', hireDate: '2023-05-20',
book: { policies: 56, gwp: 980_000, collected: 720_000, outstanding: 260_000 },
commissionTiers: [
{ lobId: 'auto', lob: 'Auto', newPct: 13, renewalPct: 9 },
{ lobId: 'health', lob: 'Health', newPct: 11, renewalPct: 7 },
{ lobId: 'life', lob: 'Life', newPct: 15, renewalPct: 10 },
{ lobId: 'property', lob: 'Property', newPct: 12, renewalPct: 8 },
{ lobId: 'general', lob: 'General risk', newPct: 11, renewalPct: 7 },
],
ytdEarned: 14_200, ytdPending: 8_800, lastLogin: '2026-03-28 11:40'
},
{
id: 'AG-005', name: 'Valentina Castro', email: 'val.castro@segur-os.com', phone: '+506 8834-5566',
role: 'Senior producer', status: 'inactive', hireDate: '2019-11-03',
book: { policies: 0, gwp: 0, collected: 0, outstanding: 0 },
commissionTiers: [
{ lobId: 'auto', lob: 'Auto', newPct: 15, renewalPct: 10 },
{ lobId: 'health', lob: 'Health', newPct: 12, renewalPct: 8 },
{ lobId: 'life', lob: 'Life', newPct: 18, renewalPct: 12 },
{ lobId: 'property', lob: 'Property', newPct: 14, renewalPct: 9 },
{ lobId: 'general', lob: 'General risk', newPct: 13, renewalPct: 8 },
],
ytdEarned: 0, ytdPending: 0, lastLogin: '2025-12-15 14:22'
},
])
/* ── State ── */
const search = ref('')
const statusFilter = ref<'all' | AgentStatus>('all')
const selectedAgent = ref<Agent | null>(null)
const addModalOpen = ref(false)
const editingCommissions = ref(false)
/* ── Filtering ── */
const filteredAgents = computed(() => {
let list = agents.value
if (statusFilter.value !== 'all') list = list.filter(a => a.status === statusFilter.value)
const q = search.value.trim().toLowerCase()
if (q) list = list.filter(a =>
a.name.toLowerCase().includes(q) ||
a.email.toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q) ||
a.role.toLowerCase().includes(q)
)
return list
})
/* ── Aggregate KPIs ── */
const kpis = computed(() => {
const active = agents.value.filter(a => a.status === 'active')
const totalBook = agents.value.reduce((s, a) => s + a.book.gwp, 0)
const totalCollected = agents.value.reduce((s, a) => s + a.book.collected, 0)
const totalOutstanding = agents.value.reduce((s, a) => s + a.book.outstanding, 0)
const totalEarned = agents.value.reduce((s, a) => s + a.ytdEarned, 0)
const totalPending = agents.value.reduce((s, a) => s + a.ytdPending, 0)
return {
activeCount: active.length,
totalCount: agents.value.length,
totalBook,
totalCollected,
totalOutstanding,
collectionRate: totalBook > 0 ? Math.round(totalCollected / totalBook * 100) : 0,
totalEarned,
totalPending,
}
})
/* ── Add agent modal ── */
const newAgent = reactive({
name: '', email: '', phone: '', role: 'Producer',
defaultNewPct: 13, defaultRenewalPct: 8,
})
function resetNewAgent() {
newAgent.name = ''; newAgent.email = ''; newAgent.phone = ''
newAgent.role = 'Producer'; newAgent.defaultNewPct = 13; newAgent.defaultRenewalPct = 8
}
const LOB_LIST = ['Auto', 'Health', 'Life', 'Property', 'General risk']
function submitNewAgent() {
if (!newAgent.name.trim() || !newAgent.email.trim()) return
const agent: Agent = {
id: `AG-${String(agents.value.length + 1).padStart(3, '0')}`,
name: newAgent.name.trim(),
email: newAgent.email.trim(),
phone: newAgent.phone.trim(),
role: newAgent.role,
status: 'active',
hireDate: new Date().toISOString().slice(0, 10),
book: { policies: 0, gwp: 0, collected: 0, outstanding: 0 },
commissionTiers: LOB_LIST.map((lob, i) => ({
lobId: lob.toLowerCase().replace(/\s+/g, '-'),
lob,
newPct: newAgent.defaultNewPct,
renewalPct: newAgent.defaultRenewalPct,
})),
ytdEarned: 0,
ytdPending: 0,
lastLogin: '—',
}
agents.value = [...agents.value, agent]
addModalOpen.value = false
resetNewAgent()
selectedAgent.value = agent
}
function toggleAgentStatus(agent: Agent) {
if (agent.status === 'active') agent.status = 'suspended'
else if (agent.status === 'suspended') agent.status = 'active'
else agent.status = 'active'
}
function resetCredentials(_agent: Agent) {
// Mock: in production this would trigger a password reset email
alert(`Password reset email would be sent to ${_agent.email}`)
}
/* ── Helpers ── */
function fmtMoney(n: number) {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`
return `$${n.toLocaleString()}`
}
const statusMeta: Record<AgentStatus, { label: string; cls: string; dot: string }> = {
active: { label: 'Active', cls: 'ag-status-active', dot: '#0f7b5f' },
inactive: { label: 'Inactive', cls: 'ag-status-inactive', dot: '#8a8a86' },
suspended: { label: 'Suspended', cls: 'ag-status-suspended', dot: '#c27b1a' },
}
const roleOptions = ['Junior producer', 'Producer', 'Senior producer', 'Account executive', 'Manager']
</script>
<template>
<div class="ag-page">
<!-- Header -->
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<NuxtLink to="/settings" class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
<UIcon name="i-heroicons-arrow-left" style="width: 16px; height: 16px;" />
</NuxtLink>
</div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Agents & commissions</h1>
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
Manage producer accounts, credentials, and commission schedules. Commissions are calculated on <strong class="font-medium text-[var(--text-primary)]">collected cashflow only</strong> unpaid or late premiums do not generate commission until received.
</p>
</div>
<button type="button" class="ag-btn-primary" @click="addModalOpen = true; resetNewAgent()">
<UIcon name="i-heroicons-user-plus" style="width: 14px; height: 14px;" />
Add agent
</button>
</div>
<!-- KPI strip -->
<div class="ag-kpi-strip">
<div class="ag-kpi">
<p class="ag-kpi-label">Active agents</p>
<p class="ag-kpi-value">{{ kpis.activeCount }}<span class="ag-kpi-sub"> / {{ kpis.totalCount }}</span></p>
</div>
<div class="ag-kpi-div" />
<div class="ag-kpi">
<p class="ag-kpi-label">Total book</p>
<p class="ag-kpi-value">{{ fmtMoney(kpis.totalBook) }}</p>
</div>
<div class="ag-kpi-div" />
<div class="ag-kpi">
<p class="ag-kpi-label">Collected</p>
<p class="ag-kpi-value">{{ fmtMoney(kpis.totalCollected) }} <span class="ag-kpi-sub">{{ kpis.collectionRate }}%</span></p>
</div>
<div class="ag-kpi-div" />
<div class="ag-kpi">
<p class="ag-kpi-label">Outstanding</p>
<p class="ag-kpi-value ag-kpi-warn">{{ fmtMoney(kpis.totalOutstanding) }}</p>
</div>
<div class="ag-kpi-div" />
<div class="ag-kpi">
<p class="ag-kpi-label">YTD commissions paid</p>
<p class="ag-kpi-value">{{ fmtMoney(kpis.totalEarned) }}</p>
</div>
<div class="ag-kpi-div" />
<div class="ag-kpi">
<p class="ag-kpi-label">Pending (uncollected)</p>
<p class="ag-kpi-value ag-kpi-warn">{{ fmtMoney(kpis.totalPending) }}</p>
</div>
</div>
<!-- Cashflow commission explainer -->
<div class="ag-explainer">
<UIcon name="i-heroicons-information-circle" style="width: 16px; height: 16px; flex-shrink: 0; color: #01696f;" />
<div>
<p class="text-[12px] font-medium text-[var(--text-primary)]">Cashflow-based commission model</p>
<p class="text-[11px] text-[var(--text-muted)] mt-0.5">
Agents earn commission only on premiums actually collected. If a client is overdue or in grace period,
the corresponding commission moves to "pending" and is not payable until funds are received. This protects
the brokerage from paying out on uncollected revenue.
</p>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<div class="ag-search-wrap">
<UIcon name="i-heroicons-magnifying-glass" style="width: 14px; height: 14px; color: #8a8a86;" />
<input v-model="search" type="text" class="ag-search" placeholder="Search agents..." />
</div>
<div class="ag-filter-tabs">
<button type="button" class="ag-filter-tab" :class="statusFilter === 'all' ? 'ag-filter-on' : ''" @click="statusFilter = 'all'">All</button>
<button type="button" class="ag-filter-tab" :class="statusFilter === 'active' ? 'ag-filter-on' : ''" @click="statusFilter = 'active'">Active</button>
<button type="button" class="ag-filter-tab" :class="statusFilter === 'suspended' ? 'ag-filter-on' : ''" @click="statusFilter = 'suspended'">Suspended</button>
<button type="button" class="ag-filter-tab" :class="statusFilter === 'inactive' ? 'ag-filter-on' : ''" @click="statusFilter = 'inactive'">Inactive</button>
</div>
</div>
<!-- Agent list + detail split -->
<div class="ag-split">
<!-- Agent table -->
<div class="ag-card ag-table-wrap">
<table class="ag-table">
<thead>
<tr>
<th>Agent</th>
<th>Role</th>
<th>Policies</th>
<th>Book GWP</th>
<th>Collected</th>
<th>YTD earned</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="agent in filteredAgents"
:key="agent.id"
class="ag-row"
:class="selectedAgent?.id === agent.id ? 'ag-row-selected' : ''"
@click="selectedAgent = agent; editingCommissions = false"
>
<td>
<div class="flex items-center gap-2.5">
<div class="ag-avatar">{{ agent.name.split(' ').map(w => w[0]).join('').slice(0, 2) }}</div>
<div class="min-w-0">
<p class="text-[13px] font-medium text-[var(--text-primary)] truncate">{{ agent.name }}</p>
<p class="text-[11px] text-[var(--text-muted)] truncate">{{ agent.email }}</p>
</div>
</div>
</td>
<td class="text-[12px] text-[var(--text-muted)]">{{ agent.role }}</td>
<td class="text-[13px] tabular-nums">{{ agent.book.policies }}</td>
<td class="text-[13px] font-medium tabular-nums">{{ fmtMoney(agent.book.gwp) }}</td>
<td>
<div class="flex items-center gap-1.5">
<span class="text-[13px] tabular-nums">{{ fmtMoney(agent.book.collected) }}</span>
<span v-if="agent.book.outstanding > 0" class="ag-outstanding-badge">{{ fmtMoney(agent.book.outstanding) }} due</span>
</div>
</td>
<td class="text-[13px] font-semibold tabular-nums">{{ fmtMoney(agent.ytdEarned) }}</td>
<td>
<span :class="statusMeta[agent.status].cls">
<span class="ag-status-dot" :style="`background: ${statusMeta[agent.status].dot}`" />
{{ statusMeta[agent.status].label }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-if="filteredAgents.length === 0" class="px-6 py-10 text-center">
<p class="text-[13px] text-[var(--text-muted)]">No agents match your search.</p>
</div>
</div>
<!-- Detail panel -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-4"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 translate-x-4"
>
<div v-if="selectedAgent" class="ag-detail">
<div class="ag-card">
<!-- Agent header -->
<div class="ag-detail-header">
<div class="flex items-center gap-3">
<div class="ag-avatar-lg">{{ selectedAgent.name.split(' ').map(w => w[0]).join('').slice(0, 2) }}</div>
<div>
<h3 class="text-[16px] font-semibold text-[var(--text-primary)]">{{ selectedAgent.name }}</h3>
<p class="text-[12px] text-[var(--text-muted)]">{{ selectedAgent.id }} · {{ selectedAgent.role }}</p>
</div>
</div>
<button type="button" class="ag-close" @click="selectedAgent = null">
<UIcon name="i-heroicons-x-mark" style="width: 16px; height: 16px;" />
</button>
</div>
<!-- Contact info -->
<div class="ag-detail-section">
<p class="ag-detail-label">Contact</p>
<div class="ag-detail-grid">
<div>
<p class="ag-detail-key">Email</p>
<p class="ag-detail-val">{{ selectedAgent.email }}</p>
</div>
<div>
<p class="ag-detail-key">Phone</p>
<p class="ag-detail-val">{{ selectedAgent.phone }}</p>
</div>
<div>
<p class="ag-detail-key">Hire date</p>
<p class="ag-detail-val">{{ selectedAgent.hireDate }}</p>
</div>
<div>
<p class="ag-detail-key">Last login</p>
<p class="ag-detail-val">{{ selectedAgent.lastLogin }}</p>
</div>
</div>
</div>
<!-- Book summary -->
<div class="ag-detail-section">
<p class="ag-detail-label">Book of business</p>
<div class="ag-detail-metrics">
<div class="ag-metric">
<p class="ag-metric-val">{{ selectedAgent.book.policies }}</p>
<p class="ag-metric-label">Policies</p>
</div>
<div class="ag-metric">
<p class="ag-metric-val">{{ fmtMoney(selectedAgent.book.gwp) }}</p>
<p class="ag-metric-label">GWP</p>
</div>
<div class="ag-metric">
<p class="ag-metric-val">{{ fmtMoney(selectedAgent.book.collected) }}</p>
<p class="ag-metric-label">Collected</p>
</div>
<div class="ag-metric">
<p class="ag-metric-val" :class="selectedAgent.book.outstanding > 0 ? 'text-[#c27b1a]' : ''">{{ fmtMoney(selectedAgent.book.outstanding) }}</p>
<p class="ag-metric-label">Outstanding</p>
</div>
</div>
<!-- Collection progress bar -->
<div v-if="selectedAgent.book.gwp > 0" class="mt-3">
<div class="ag-coll-bar">
<div class="ag-coll-fill" :style="`width: ${Math.round(selectedAgent.book.collected / selectedAgent.book.gwp * 100)}%`" />
</div>
<p class="text-[10px] text-[var(--text-muted)] mt-1">
{{ Math.round(selectedAgent.book.collected / selectedAgent.book.gwp * 100) }}% collected commission payable on collected portion only
</p>
</div>
</div>
<!-- Commission earnings -->
<div class="ag-detail-section">
<p class="ag-detail-label">YTD commission</p>
<div class="ag-detail-metrics">
<div class="ag-metric">
<p class="ag-metric-val text-[#0f7b5f]">{{ fmtMoney(selectedAgent.ytdEarned) }}</p>
<p class="ag-metric-label">Earned (paid)</p>
</div>
<div class="ag-metric">
<p class="ag-metric-val text-[#c27b1a]">{{ fmtMoney(selectedAgent.ytdPending) }}</p>
<p class="ag-metric-label">Pending (uncollected)</p>
</div>
</div>
</div>
<!-- Commission schedule -->
<div class="ag-detail-section">
<div class="flex items-center justify-between">
<p class="ag-detail-label">Commission schedule by LOB</p>
<button
type="button"
class="ag-edit-btn"
@click="editingCommissions = !editingCommissions"
>
<UIcon :name="editingCommissions ? 'i-heroicons-check' : 'i-heroicons-pencil-square'" style="width: 12px; height: 12px;" />
{{ editingCommissions ? 'Done' : 'Edit' }}
</button>
</div>
<table class="ag-comm-table">
<thead>
<tr>
<th>Line of business</th>
<th>New biz %</th>
<th>Renewal %</th>
</tr>
</thead>
<tbody>
<tr v-for="tier in selectedAgent.commissionTiers" :key="tier.lobId">
<td class="text-[12px] font-medium text-[var(--text-primary)]">{{ tier.lob }}</td>
<td>
<input
v-if="editingCommissions"
v-model.number="tier.newPct"
type="number"
min="0"
max="100"
step="0.5"
class="ag-pct-input"
/>
<span v-else class="text-[13px] tabular-nums font-semibold text-[var(--text-primary)]">{{ tier.newPct }}%</span>
</td>
<td>
<input
v-if="editingCommissions"
v-model.number="tier.renewalPct"
type="number"
min="0"
max="100"
step="0.5"
class="ag-pct-input"
/>
<span v-else class="text-[13px] tabular-nums font-semibold text-[var(--text-primary)]">{{ tier.renewalPct }}%</span>
</td>
</tr>
</tbody>
</table>
<p class="text-[10px] text-[var(--text-muted)] mt-2">
Percentages apply to collected premium only. Overdue or grace-period accounts are excluded from the calculation until payment clears.
</p>
</div>
<!-- Actions -->
<div class="ag-detail-actions">
<button type="button" class="ag-action-btn" @click="resetCredentials(selectedAgent)">
<UIcon name="i-heroicons-key" style="width: 14px; height: 14px;" />
Reset password
</button>
<button
type="button"
class="ag-action-btn"
:class="selectedAgent.status === 'active' ? 'ag-action-warn' : 'ag-action-success'"
@click="toggleAgentStatus(selectedAgent)"
>
<UIcon :name="selectedAgent.status === 'active' ? 'i-heroicons-pause-circle' : 'i-heroicons-play-circle'" style="width: 14px; height: 14px;" />
{{ selectedAgent.status === 'active' ? 'Suspend' : 'Activate' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
<!-- ADD AGENT MODAL -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="addModalOpen" class="ag-modal-overlay" @click.self="addModalOpen = false">
<div class="ag-modal">
<div class="ag-modal-head">
<h3>Add new agent</h3>
<button type="button" class="ag-close" @click="addModalOpen = false">
<UIcon name="i-heroicons-x-mark" style="width: 16px; height: 16px;" />
</button>
</div>
<div class="ag-modal-body">
<div class="ag-field">
<label class="ag-label">Full name</label>
<input v-model="newAgent.name" type="text" class="ag-input" placeholder="e.g. Ana Ramírez" @keydown.enter="submitNewAgent" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="ag-field">
<label class="ag-label">Email</label>
<input v-model="newAgent.email" type="email" class="ag-input" placeholder="agent@company.com" />
</div>
<div class="ag-field">
<label class="ag-label">Phone</label>
<input v-model="newAgent.phone" type="tel" class="ag-input" placeholder="+506 ..." />
</div>
</div>
<div class="ag-field">
<label class="ag-label">Role</label>
<select v-model="newAgent.role" class="ag-select">
<option v-for="r in roleOptions" :key="r" :value="r">{{ r }}</option>
</select>
</div>
<div class="ag-field">
<label class="ag-label">Default commission rates</label>
<p class="text-[11px] text-[var(--text-muted)] mb-2">Applied to all LOBs initially. You can customize per-LOB after creating the agent.</p>
<div class="grid grid-cols-2 gap-3">
<div class="ag-field">
<label class="ag-label-sm">New business %</label>
<input v-model.number="newAgent.defaultNewPct" type="number" min="0" max="100" step="0.5" class="ag-input" />
</div>
<div class="ag-field">
<label class="ag-label-sm">Renewal %</label>
<input v-model.number="newAgent.defaultRenewalPct" type="number" min="0" max="100" step="0.5" class="ag-input" />
</div>
</div>
</div>
<div class="ag-explainer" style="margin: 0;">
<UIcon name="i-heroicons-shield-check" style="width: 14px; height: 14px; flex-shrink: 0; color: #01696f;" />
<p class="text-[11px] text-[var(--text-muted)]">
A temporary password will be generated and sent to the agent's email. They will be required to change it on first login.
</p>
</div>
</div>
<div class="ag-modal-foot">
<button type="button" class="ag-btn-cancel" @click="addModalOpen = false">Cancel</button>
<button type="button" class="ag-btn-primary" :disabled="!newAgent.name.trim() || !newAgent.email.trim()" @click="submitNewAgent">
Create agent
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.ag-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 80rem;
margin: 0 auto;
padding-bottom: 3rem;
}
/* ── Buttons ── */
.ag-btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 8px;
border: none;
background: #01696f;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 150ms ease;
}
.ag-btn-primary:hover { background: #015a5f; }
.ag-btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.ag-btn-cancel {
padding: 7px 14px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
}
.ag-btn-cancel:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
/* ── KPI strip ── */
.ag-kpi-strip {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
border-radius: 10px;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow-x: auto;
flex-wrap: wrap;
}
.ag-kpi { min-width: 0; flex-shrink: 0; }
.ag-kpi-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; }
.ag-kpi-value { font-size: 18px; font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; margin-top: 2px; }
.ag-kpi-sub { font-size: 12px; font-weight: 500; color: #8a8a86; }
.ag-kpi-warn { color: #c27b1a; }
.ag-kpi-div { width: 1px; height: 32px; background: rgba(0,0,0,0.06); flex-shrink: 0; }
/* ── Explainer ── */
.ag-explainer {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
background: rgba(1,105,111,0.03);
border: 1px solid rgba(1,105,111,0.08);
}
/* ── Search & filters ── */
.ag-search-wrap {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
flex: 1;
max-width: 280px;
}
.ag-search {
border: none;
outline: none;
font-size: 12px;
color: var(--text-primary);
flex: 1;
background: transparent;
}
.ag-search::placeholder { color: #8a8a86; }
.ag-filter-tabs {
display: inline-flex;
gap: 1px;
padding: 2px;
border-radius: 8px;
background: rgba(0,0,0,0.04);
}
.ag-filter-tab {
padding: 4px 10px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.ag-filter-on {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
/* ── Split layout ── */
.ag-split {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
}
@media (min-width: 1024px) {
.ag-split { grid-template-columns: 1fr 380px; }
}
/* ── Card ── */
.ag-card {
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
/* ── Table ── */
.ag-table-wrap { overflow-x: auto; }
.ag-table {
width: 100%;
border-collapse: collapse;
}
.ag-table th {
padding: 10px 14px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
text-align: left;
border-bottom: 1px solid rgba(0,0,0,0.06);
white-space: nowrap;
}
.ag-table td {
padding: 10px 14px;
white-space: nowrap;
}
.ag-row {
cursor: pointer;
transition: background 100ms ease;
border-bottom: 1px solid rgba(0,0,0,0.03);
}
.ag-row:hover { background: rgba(0,0,0,0.015); }
.ag-row-selected { background: rgba(1,105,111,0.03); }
.ag-row-selected:hover { background: rgba(1,105,111,0.05); }
.ag-avatar {
width: 32px; height: 32px;
border-radius: 8px;
background: rgba(1,105,111,0.08);
color: #01696f;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ag-avatar-lg {
width: 40px; height: 40px;
border-radius: 10px;
background: rgba(1,105,111,0.08);
color: #01696f;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ag-outstanding-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 9999px;
background: rgba(194,123,26,0.08);
color: #c27b1a;
white-space: nowrap;
}
/* ── Status badges ── */
.ag-status-active, .ag-status-inactive, .ag-status-suspended {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px 2px 6px;
border-radius: 9999px;
}
.ag-status-active { background: rgba(15,123,95,0.06); color: #0f7b5f; }
.ag-status-inactive { background: rgba(0,0,0,0.04); color: #8a8a86; }
.ag-status-suspended { background: rgba(194,123,26,0.06); color: #c27b1a; }
.ag-status-dot { width: 6px; height: 6px; border-radius: 50%; }
/* ── Detail panel ── */
.ag-detail { min-width: 0; }
.ag-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.ag-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 6px;
border: none;
background: transparent;
color: #8a8a86;
cursor: pointer;
transition: all 150ms ease;
}
.ag-close:hover { background: rgba(0,0,0,0.05); color: var(--text-primary); }
.ag-detail-section {
padding: 14px 20px;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.ag-detail-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
margin-bottom: 8px;
}
.ag-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ag-detail-key { font-size: 10px; color: #8a8a86; }
.ag-detail-val { font-size: 12px; font-weight: 500; color: var(--text-primary); margin-top: 1px; }
.ag-detail-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ag-metric { text-align: center; }
.ag-metric-val { font-size: 16px; font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.ag-metric-label { font-size: 10px; color: #8a8a86; margin-top: 2px; }
/* ── Collection bar ── */
.ag-coll-bar {
height: 4px;
border-radius: 2px;
background: rgba(0,0,0,0.04);
overflow: hidden;
}
.ag-coll-fill {
height: 100%;
border-radius: 2px;
background: #01696f;
transition: width 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Commission table ── */
.ag-comm-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.ag-comm-table th {
padding: 6px 0;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
text-align: left;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.ag-comm-table td {
padding: 7px 0;
border-bottom: 1px solid rgba(0,0,0,0.03);
}
.ag-comm-table tr:last-child td { border-bottom: none; }
.ag-pct-input {
width: 60px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
font-variant-numeric: tabular-nums;
outline: none;
transition: border-color 150ms ease;
}
.ag-pct-input:focus { border-color: #01696f; }
.ag-edit-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 5px;
border: none;
background: transparent;
font-size: 11px;
font-weight: 500;
color: #01696f;
cursor: pointer;
transition: background 150ms ease;
}
.ag-edit-btn:hover { background: rgba(1,105,111,0.06); }
/* ── Agent actions ── */
.ag-detail-actions {
display: flex;
gap: 6px;
padding: 14px 20px;
flex-wrap: wrap;
}
.ag-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 7px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
}
.ag-action-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
.ag-action-warn { color: #c27b1a; border-color: rgba(194,123,26,0.15); }
.ag-action-warn:hover { background: rgba(194,123,26,0.04); border-color: rgba(194,123,26,0.25); color: #c27b1a; }
.ag-action-success { color: #0f7b5f; border-color: rgba(15,123,95,0.15); }
.ag-action-success:hover { background: rgba(15,123,95,0.04); border-color: rgba(15,123,95,0.25); color: #0f7b5f; }
/* ── Modal ── */
.ag-modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(2px);
}
.ag-modal {
width: 100%;
max-width: 480px;
border-radius: 12px;
background: #fff;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08);
overflow: hidden;
}
.ag-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 0;
}
.ag-modal-head h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.ag-modal-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ag-modal-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid rgba(0,0,0,0.06);
}
/* ── Form elements ── */
.ag-field { display: flex; flex-direction: column; gap: 4px; }
.ag-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
}
.ag-label-sm {
font-size: 10px;
font-weight: 500;
color: #8a8a86;
}
.ag-input {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 13px;
color: var(--text-primary);
background: #fff;
outline: none;
transition: border-color 150ms ease;
}
.ag-input:focus { border-color: #01696f; }
.ag-select {
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 13px;
color: var(--text-primary);
background: #fff;
outline: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a86' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
transition: border-color 150ms ease;
}
.ag-select:focus { border-color: #01696f; }
/* ── Responsive ── */
@media (max-width: 768px) {
.ag-detail-metrics { grid-template-columns: repeat(2, 1fr); }
.ag-kpi-strip { gap: 12px; }
}
</style>