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

2725 lines
97 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
definePageMeta({ ssr: false })
usePageTitle('Collections')
// ── Types ──
interface ContactNote {
date: string
agent: string
note: string
}
interface PaymentPlan {
totalAmount: number
installments: number
nextDue: string
amountPer: number
}
type AccountStatus = 'pending' | 'contacted' | 'payment_plan' | 'escalated' | 'resolved'
type AccountGrade = 'A' | 'B' | 'C' | 'D'
type LOB = 'Auto' | 'Health' | 'Life' | 'General Risk'
interface OverdueAccount {
id: string
customerName: string
customerId: string
policyNumber: string
insurer: string
lob: LOB
daysOverdue: number
overdueAmount: number
totalPremium: number
grade: AccountGrade
assignedAgent: string | null
status: AccountStatus
lastContactDate: string | null
notes: ContactNote[]
paymentPlan: PaymentPlan | null
economicGroup: string | null
}
interface AssignmentRule {
id: string
insurer: string | null
policyType: LOB | null
grade: AccountGrade | null
specificAccount: string | null
assignedAgent: string
enabled: boolean
}
interface UploadRecord {
id: string
filename: string
date: string
policiesPaid: number
newOverdue: number
}
// ── Mock Data ──
const agents = ['R. Vega', 'A. Morales', 'L. Castro', 'M. Jimenez', 'K. Solano']
const accounts = ref<OverdueAccount[]>([
{
id: 'COL-001', customerName: 'Transportes del Norte S.A.', customerId: 'CUS-112',
policyNumber: 'POL-4401', insurer: 'ASSA', lob: 'Auto',
daysOverdue: 95, overdueAmount: 18500, totalPremium: 42000, grade: 'D',
assignedAgent: 'A. Morales', status: 'escalated', lastContactDate: '2026-03-15',
notes: [
{ date: '2026-03-15', agent: 'A. Morales', note: 'Client unreachable by phone. Sent formal demand letter via email.' },
{ date: '2026-02-28', agent: 'A. Morales', note: 'Spoke with accounts payable. They claim cash flow issues.' },
{ date: '2026-02-10', agent: 'R. Vega', note: 'Initial contact. Client acknowledged debt, requested extension.' },
],
paymentPlan: null, economicGroup: 'Grupo Transportes Norte',
},
{
id: 'COL-002', customerName: 'Maria Fernanda Rojas', customerId: 'CUS-238',
policyNumber: 'POL-4520', insurer: 'INS', lob: 'Health',
daysOverdue: 72, overdueAmount: 3200, totalPremium: 8400, grade: 'C',
assignedAgent: 'L. Castro', status: 'contacted', lastContactDate: '2026-03-22',
notes: [
{ date: '2026-03-22', agent: 'L. Castro', note: 'Client says will pay next week. Following up Monday.' },
],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-003', customerName: 'Hotel Pacífico Resort', customerId: 'CUS-045',
policyNumber: 'POL-3899', insurer: 'ASSA', lob: 'General Risk',
daysOverdue: 105, overdueAmount: 42000, totalPremium: 96000, grade: 'D',
assignedAgent: 'A. Morales', status: 'escalated', lastContactDate: '2026-03-18',
notes: [
{ date: '2026-03-18', agent: 'A. Morales', note: 'Escalated to legal department. Property lien under review.' },
{ date: '2026-03-01', agent: 'A. Morales', note: 'Multiple attempts to contact general manager. No response.' },
],
paymentPlan: null, economicGroup: 'Grupo Pacífico',
},
{
id: 'COL-004', customerName: 'Carlos Enrique Vargas', customerId: 'CUS-389',
policyNumber: 'POL-4610', insurer: 'Qualitas', lob: 'Auto',
daysOverdue: 34, overdueAmount: 890, totalPremium: 2400, grade: 'B',
assignedAgent: 'R. Vega', status: 'payment_plan', lastContactDate: '2026-03-28',
notes: [
{ date: '2026-03-28', agent: 'R. Vega', note: 'Set up 3-installment plan. First payment due Apr 5.' },
],
paymentPlan: { totalAmount: 890, installments: 3, nextDue: '2026-04-05', amountPer: 297 }, economicGroup: null,
},
{
id: 'COL-005', customerName: 'Clínica Santa María', customerId: 'CUS-067',
policyNumber: 'POL-4122', insurer: 'Blue Cross', lob: 'Health',
daysOverdue: 61, overdueAmount: 15800, totalPremium: 54000, grade: 'C',
assignedAgent: 'L. Castro', status: 'contacted', lastContactDate: '2026-03-25',
notes: [
{ date: '2026-03-25', agent: 'L. Castro', note: 'Clinic administrator says insurance check is being processed. ETA 2 weeks.' },
],
paymentPlan: null, economicGroup: 'Grupo Clínica Santa María',
},
{
id: 'COL-006', customerName: 'Roberto Alfaro Mora', customerId: 'CUS-412',
policyNumber: 'POL-4700', insurer: 'Pan-American Life', lob: 'Life',
daysOverdue: 28, overdueAmount: 1450, totalPremium: 6000, grade: 'A',
assignedAgent: null, status: 'pending', lastContactDate: null,
notes: [],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-007', customerName: 'Farmacia Central S.A.', customerId: 'CUS-156',
policyNumber: 'POL-4055', insurer: 'INS', lob: 'General Risk',
daysOverdue: 88, overdueAmount: 6700, totalPremium: 18000, grade: 'D',
assignedAgent: 'M. Jimenez', status: 'escalated', lastContactDate: '2026-03-20',
notes: [
{ date: '2026-03-20', agent: 'M. Jimenez', note: 'Formal notice sent. 15-day deadline to pay or policy cancellation.' },
{ date: '2026-03-05', agent: 'M. Jimenez', note: 'Owner says business is slow. Cannot commit to date.' },
],
paymentPlan: null, economicGroup: 'Colectivo Farmacias Nacionales',
},
{
id: 'COL-008', customerName: 'Ana Lucía Bermúdez', customerId: 'CUS-501',
policyNumber: 'POL-4811', insurer: 'ASSA', lob: 'Auto',
daysOverdue: 15, overdueAmount: 520, totalPremium: 1800, grade: 'A',
assignedAgent: 'R. Vega', status: 'contacted', lastContactDate: '2026-04-01',
notes: [
{ date: '2026-04-01', agent: 'R. Vega', note: 'Client will pay by Apr 10. Reminder scheduled.' },
],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-009', customerName: 'Constructora Montes', customerId: 'CUS-078',
policyNumber: 'POL-3945', insurer: 'INS', lob: 'General Risk',
daysOverdue: 52, overdueAmount: 22400, totalPremium: 68000, grade: 'C',
assignedAgent: 'A. Morales', status: 'payment_plan', lastContactDate: '2026-03-30',
notes: [
{ date: '2026-03-30', agent: 'A. Morales', note: 'Negotiated 4-payment plan. CFO signed agreement.' },
],
paymentPlan: { totalAmount: 22400, installments: 4, nextDue: '2026-04-15', amountPer: 5600 }, economicGroup: 'Grupo Pacífico',
},
{
id: 'COL-010', customerName: 'Jorge Luis Herrera', customerId: 'CUS-299',
policyNumber: 'POL-4333', insurer: 'Qualitas', lob: 'Auto',
daysOverdue: 40, overdueAmount: 1100, totalPremium: 3200, grade: 'B',
assignedAgent: 'K. Solano', status: 'contacted', lastContactDate: '2026-03-26',
notes: [
{ date: '2026-03-26', agent: 'K. Solano', note: 'Left voicemail. Client has not returned call.' },
],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-011', customerName: 'Seguros y Más Ltda.', customerId: 'CUS-190',
policyNumber: 'POL-4200', insurer: 'Blue Cross', lob: 'Health',
daysOverdue: 92, overdueAmount: 28000, totalPremium: 120000, grade: 'D',
assignedAgent: 'A. Morales', status: 'escalated', lastContactDate: '2026-03-19',
notes: [
{ date: '2026-03-19', agent: 'A. Morales', note: 'Referred to external collections agency. Internal efforts exhausted.' },
],
paymentPlan: null, economicGroup: 'Grupo Clínica Santa María',
},
{
id: 'COL-012', customerName: 'Patricia Solís Chen', customerId: 'CUS-445',
policyNumber: 'POL-4650', insurer: 'Pan-American Life', lob: 'Life',
daysOverdue: 45, overdueAmount: 2800, totalPremium: 9600, grade: 'B',
assignedAgent: 'L. Castro', status: 'payment_plan', lastContactDate: '2026-03-29',
notes: [
{ date: '2026-03-29', agent: 'L. Castro', note: 'Agreed to 2-payment plan. First half received.' },
],
paymentPlan: { totalAmount: 2800, installments: 2, nextDue: '2026-04-12', amountPer: 1400 }, economicGroup: null,
},
{
id: 'COL-013', customerName: 'Inversiones Tropicales', customerId: 'CUS-034',
policyNumber: 'POL-3810', insurer: 'ASSA', lob: 'General Risk',
daysOverdue: 67, overdueAmount: 9500, totalPremium: 36000, grade: 'C',
assignedAgent: 'M. Jimenez', status: 'contacted', lastContactDate: '2026-03-21',
notes: [
{ date: '2026-03-21', agent: 'M. Jimenez', note: 'Meeting scheduled with company director for Apr 3.' },
],
paymentPlan: null, economicGroup: 'Grupo Transportes Norte',
},
{
id: 'COL-014', customerName: 'Diego Alonso Quesada', customerId: 'CUS-520',
policyNumber: 'POL-4880', insurer: 'INS', lob: 'Auto',
daysOverdue: 22, overdueAmount: 750, totalPremium: 2100, grade: 'A',
assignedAgent: null, status: 'pending', lastContactDate: null,
notes: [],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-015', customerName: 'Restaurante El Fogón', customerId: 'CUS-178',
policyNumber: 'POL-4150', insurer: 'Qualitas', lob: 'Auto',
daysOverdue: 58, overdueAmount: 3400, totalPremium: 12000, grade: 'C',
assignedAgent: 'K. Solano', status: 'contacted', lastContactDate: '2026-03-24',
notes: [
{ date: '2026-03-24', agent: 'K. Solano', note: 'Owner promises partial payment by month end. Will follow up.' },
],
paymentPlan: null, economicGroup: 'Colectivo Farmacias Nacionales',
},
{
id: 'COL-016', customerName: 'Laura Gabriela Monge', customerId: 'CUS-610',
policyNumber: 'POL-4920', insurer: 'Blue Cross', lob: 'Health',
daysOverdue: 31, overdueAmount: 1900, totalPremium: 7200, grade: 'B',
assignedAgent: 'R. Vega', status: 'contacted', lastContactDate: '2026-04-02',
notes: [
{ date: '2026-04-02', agent: 'R. Vega', note: 'Client aware of overdue status. Waiting for employer reimbursement.' },
],
paymentPlan: null, economicGroup: null,
},
{
id: 'COL-017', customerName: 'Distribuidora Tica S.A.', customerId: 'CUS-090',
policyNumber: 'POL-3970', insurer: 'Pan-American Life', lob: 'Life',
daysOverdue: 110, overdueAmount: 14200, totalPremium: 48000, grade: 'D',
assignedAgent: 'A. Morales', status: 'escalated', lastContactDate: '2026-03-12',
notes: [
{ date: '2026-03-12', agent: 'A. Morales', note: 'Company appears to have ceased operations. Investigating.' },
],
paymentPlan: null, economicGroup: null,
},
])
// ── Role ──
const isManager = ref(true) // mock — in production from auth
// ── Current user (mock) ──
const currentUser = 'R. Vega'
// ── Grade Logic ──
function computeGrade(daysOverdue: number): AccountGrade {
if (daysOverdue >= 90) return 'D'
if (daysOverdue >= 60) return 'C'
if (daysOverdue >= 30) return 'B'
return 'A'
}
// ── Tabs ──
type MainTab = 'accounts' | 'assigned_to_me' | 'my_lists' | 'unassigned' | 'assignment_rules'
const activeTab = ref<MainTab>('accounts')
// ── Search & Filters ──
const searchQuery = ref('')
const sortBy = ref('amount_desc')
const filterInsurer = ref('')
const filterLob = ref('')
const filterDaysRange = ref('')
const filterAgent = ref('')
const sortOptions = [
{ label: 'Amount (High to Low)', value: 'amount_desc' },
{ label: 'Amount (Low to High)', value: 'amount_asc' },
{ label: 'Days Overdue', value: 'days_desc' },
{ label: 'Alphabetical', value: 'alpha' },
{ label: 'Insurer', value: 'insurer' },
{ label: 'Grade', value: 'grade' },
]
const insurers = ['ASSA', 'INS', 'Blue Cross', 'Pan-American Life', 'Qualitas']
const lobOptions: LOB[] = ['Auto', 'Health', 'Life', 'General Risk']
const daysRangeOptions = [
{ label: '30+ days', value: '30' },
{ label: '60+ days', value: '60' },
{ label: '90+ days', value: '90' },
]
const filteredAccounts = computed(() => {
let result = [...accounts.value]
// Search
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(a =>
a.customerName.toLowerCase().includes(q) ||
a.policyNumber.toLowerCase().includes(q) ||
a.insurer.toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
)
}
// Filter insurer
if (filterInsurer.value) {
result = result.filter(a => a.insurer === filterInsurer.value)
}
// Filter LOB
if (filterLob.value) {
result = result.filter(a => a.lob === filterLob.value)
}
// Filter days range
if (filterDaysRange.value) {
const min = parseInt(filterDaysRange.value)
result = result.filter(a => a.daysOverdue >= min)
}
// Filter agent
if (filterAgent.value) {
if (filterAgent.value === 'Unassigned') {
result = result.filter(a => !a.assignedAgent)
} else {
result = result.filter(a => a.assignedAgent === filterAgent.value)
}
}
// Sort
switch (sortBy.value) {
case 'amount_desc': result.sort((a, b) => b.overdueAmount - a.overdueAmount); break
case 'amount_asc': result.sort((a, b) => a.overdueAmount - b.overdueAmount); break
case 'days_desc': result.sort((a, b) => b.daysOverdue - a.daysOverdue); break
case 'alpha': result.sort((a, b) => a.customerName.localeCompare(b.customerName)); break
case 'insurer': result.sort((a, b) => a.insurer.localeCompare(b.insurer)); break
case 'grade': result.sort((a, b) => a.grade.localeCompare(b.grade)); break
}
return result
})
// ── KPIs ──
const kpis = computed(() => {
const all = accounts.value
const overdue = all.filter(a => a.status !== 'resolved')
const totalOverdue = overdue.reduce((s, a) => s + a.overdueAmount, 0)
const thirtyDay = overdue.filter(a => a.daysOverdue >= 30 && a.daysOverdue < 60).length
const sixtyDay = overdue.filter(a => a.daysOverdue >= 60 && a.daysOverdue < 90).length
const ninetyPlus = overdue.filter(a => a.daysOverdue >= 90).length
const resolved = all.filter(a => a.status === 'resolved').length
const collectionRate = all.length > 0 ? Math.round((resolved / all.length) * 100) : 0
return {
totalAccounts: overdue.length,
totalOverdue,
thirtyDay,
sixtyDay,
ninetyPlus,
collectionRate,
}
})
// ── Expanded Rows ──
const expandedRows = ref<Set<string>>(new Set())
function toggleRow(id: string) {
if (expandedRows.value.has(id)) {
expandedRows.value.delete(id)
} else {
expandedRows.value.add(id)
}
}
// ── Status Management ──
const statusMeta: Record<AccountStatus, { label: string; class: string }> = {
pending: { label: 'Pending', class: 'col-st-pending' },
contacted: { label: 'Contacted', class: 'col-st-contacted' },
payment_plan: { label: 'Payment Plan', class: 'col-st-plan' },
escalated: { label: 'Escalated', class: 'col-st-escalated' },
resolved: { label: 'Resolved', class: 'col-st-resolved' },
}
const allStatuses: AccountStatus[] = ['pending', 'contacted', 'payment_plan', 'escalated', 'resolved']
function updateAccountStatus(id: string, newStatus: AccountStatus) {
const account = accounts.value.find(a => a.id === id)
if (account) account.status = newStatus
}
// ── Grade styling ──
const gradeMeta: Record<AccountGrade, { class: string; tooltip: string }> = {
A: { class: 'col-grade-a', tooltip: 'Grade A — under 30 days overdue' },
B: { class: 'col-grade-b', tooltip: 'Grade B — 30-59 days overdue' },
C: { class: 'col-grade-c', tooltip: 'Grade C — 60-89 days overdue' },
D: { class: 'col-grade-d', tooltip: 'Grade D — 90+ days overdue' },
}
// ── Add Note ──
const noteModal = ref<{ open: boolean; accountId: string; text: string }>({ open: false, accountId: '', text: '' })
function openNoteModal(accountId: string) {
noteModal.value = { open: true, accountId, text: '' }
}
function saveNote() {
const account = accounts.value.find(a => a.id === noteModal.value.accountId)
if (account && noteModal.value.text.trim()) {
account.notes.unshift({
date: new Date().toISOString().split('T')[0],
agent: 'Current User',
note: noteModal.value.text.trim(),
})
account.lastContactDate = new Date().toISOString().split('T')[0]
}
noteModal.value = { open: false, accountId: '', text: '' }
}
// ── Configurations Tab ──
type ConfigSection = 'upload' | 'rules' | 'settings'
const activeConfigSection = ref<ConfigSection>('upload')
// Upload state
const isDragOver = ref(false)
const uploadComplete = ref(false)
const uploadSummary = ref({ policiesPaid: 12, newOverdue: 3 })
const uploadHistory = ref<UploadRecord[]>([
{ id: 'UP-003', filename: 'ASSA_payments_march_2026.xlsx', date: '2026-03-28', policiesPaid: 18, newOverdue: 5 },
{ id: 'UP-002', filename: 'INS_cobros_feb_2026.csv', date: '2026-02-27', policiesPaid: 9, newOverdue: 2 },
{ id: 'UP-001', filename: 'BlueCross_Q1_report.xlsx', date: '2026-01-31', policiesPaid: 24, newOverdue: 7 },
])
function handleDrop() {
isDragOver.value = false
uploadComplete.value = true
setTimeout(() => { uploadComplete.value = false }, 8000)
}
function handleFileSelect() {
uploadComplete.value = true
setTimeout(() => { uploadComplete.value = false }, 8000)
}
// Assignment rules
const assignmentRules = ref<AssignmentRule[]>([
{ id: 'R-001', insurer: 'ASSA', policyType: 'Auto', grade: null, specificAccount: null, assignedAgent: 'R. Vega', enabled: true },
{ id: 'R-002', insurer: null, policyType: null, grade: 'D', specificAccount: null, assignedAgent: 'A. Morales', enabled: true },
{ id: 'R-003', insurer: 'Blue Cross', policyType: 'Health', grade: null, specificAccount: null, assignedAgent: 'L. Castro', enabled: true },
{ id: 'R-004', insurer: 'Qualitas', policyType: null, grade: null, specificAccount: null, assignedAgent: 'K. Solano', enabled: false },
])
const showAddRule = ref(false)
const newRule = ref<AssignmentRule>({
id: '', insurer: null, policyType: null, grade: null, specificAccount: null, assignedAgent: '', enabled: true,
})
function addRule() {
if (!newRule.value.assignedAgent) return
assignmentRules.value.push({
...newRule.value,
id: `R-${String(assignmentRules.value.length + 1).padStart(3, '0')}`,
})
newRule.value = { id: '', insurer: null, policyType: null, grade: null, specificAccount: null, assignedAgent: '', enabled: true }
showAddRule.value = false
}
function removeRule(id: string) {
assignmentRules.value = assignmentRules.value.filter(r => r.id !== id)
}
function describeRule(rule: AssignmentRule): string {
const parts: string[] = []
if (rule.insurer) parts.push(rule.insurer)
else parts.push('All Insurers')
if (rule.policyType) parts.push(rule.policyType)
if (rule.grade) parts.push(`Grade ${rule.grade}`)
if (rule.specificAccount) parts.push(rule.specificAccount)
return parts.join(' + ')
}
// Collection settings
const settingsAutoEscalate = ref(true)
const settingsSendReminders = ref(true)
const settingsNotifyAgent = ref(false)
const settingsEscalationThreshold = ref('90')
// ── Formatting Helpers ──
function formatCurrency(n: number): string {
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0 })
}
function formatDate(d: string | null): string {
if (!d) return '—'
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
function agingColor(days: number): string {
if (days >= 90) return 'col-aging-critical'
if (days >= 60) return 'col-aging-high'
if (days >= 30) return 'col-aging-medium'
return 'col-aging-low'
}
// ═══════════════════════════════════════
// MY LISTS
// ═══════════════════════════════════════
interface CollectionList {
id: string
name: string
createdAt: string
accountIds: string[]
}
const myLists = ref<CollectionList[]>([
{
id: 'CL-001', name: 'Downtown route Apr 7', createdAt: '2026-04-07',
accountIds: ['COL-004', 'COL-008', 'COL-010'],
},
{
id: 'CL-002', name: 'High-value escalated', createdAt: '2026-04-03',
accountIds: ['COL-001', 'COL-003', 'COL-007'],
},
])
const selectedForList = ref<Set<string>>(new Set())
const showCreateList = ref(false)
const newListName = ref('')
// Which accounts the current agent can pick from (assigned to them + unassigned)
const myAvailableAccounts = computed(() =>
accounts.value.filter(a => a.assignedAgent === currentUser || !a.assignedAgent)
)
function toggleListSelect(id: string) {
if (selectedForList.value.has(id)) {
selectedForList.value.delete(id)
} else {
selectedForList.value.add(id)
}
}
function selectAllAvailable() {
if (selectedForList.value.size === myAvailableAccounts.value.length) {
selectedForList.value.clear()
} else {
myAvailableAccounts.value.forEach(a => selectedForList.value.add(a.id))
}
}
function createList() {
if (!newListName.value.trim() || selectedForList.value.size === 0) return
myLists.value.unshift({
id: `CL-${String(myLists.value.length + 1).padStart(3, '0')}`,
name: newListName.value.trim(),
createdAt: new Date().toISOString().split('T')[0],
accountIds: [...selectedForList.value],
})
selectedForList.value.clear()
newListName.value = ''
showCreateList.value = false
}
function deleteList(id: string) {
myLists.value = myLists.value.filter(l => l.id !== id)
}
const expandedList = ref<string | null>(null)
function toggleListExpand(id: string) {
expandedList.value = expandedList.value === id ? null : id
}
function getAccountById(id: string) {
return accounts.value.find(a => a.id === id)
}
// ═══════════════════════════════════════
// ASSIGNED TO ME
// ═══════════════════════════════════════
const myAccounts = computed(() =>
accounts.value.filter(a => a.assignedAgent === currentUser)
)
// ═══════════════════════════════════════
// ASSIGN ACCOUNTS (manager only)
// ═══════════════════════════════════════
const assignSearch = ref('')
const assignFilterStatus = ref('unassigned')
// Smart assign criteria
type SmartAssignMode = 'grade' | 'group' | 'insurer' | 'lob' | 'insurer_lob' | 'aging' | 'custom'
const showSmartAssign = ref(false)
const smartMode = ref<SmartAssignMode>('grade')
const smartAgent = ref('')
const smartGrade = ref<AccountGrade | ''>('')
const smartGroup = ref('')
const smartInsurer = ref('')
const smartLob = ref<LOB | ''>('')
const smartAging = ref('')
const smartCustomIds = ref<Set<string>>(new Set())
const economicGroups = computed(() => {
const groups = new Set<string>()
accounts.value.forEach(a => { if (a.economicGroup) groups.add(a.economicGroup) })
return [...groups].sort()
})
const agingOptions = [
{ label: '15+ days', value: '15' },
{ label: '30+ days', value: '30' },
{ label: '60+ days', value: '60' },
{ label: '90+ days', value: '90' },
]
const smartMatchCount = computed(() => {
return smartMatchingAccounts.value.length
})
const smartMatchingAccounts = computed(() => {
switch (smartMode.value) {
case 'grade':
return smartGrade.value ? accounts.value.filter(a => a.grade === smartGrade.value) : []
case 'group':
return smartGroup.value ? accounts.value.filter(a => a.economicGroup === smartGroup.value) : []
case 'insurer':
return smartInsurer.value ? accounts.value.filter(a => a.insurer === smartInsurer.value) : []
case 'lob':
return smartLob.value ? accounts.value.filter(a => a.lob === smartLob.value) : []
case 'insurer_lob':
return (smartInsurer.value && smartLob.value)
? accounts.value.filter(a => a.insurer === smartInsurer.value && a.lob === smartLob.value)
: []
case 'aging':
if (!smartAging.value) return []
const min = parseInt(smartAging.value)
return accounts.value.filter(a => a.daysOverdue >= min)
case 'custom':
return accounts.value.filter(a => smartCustomIds.value.has(a.id))
default:
return []
}
})
function applySmartAssign() {
if (!smartAgent.value || smartMatchingAccounts.value.length === 0) return
smartMatchingAccounts.value.forEach(a => { a.assignedAgent = smartAgent.value })
// reset
smartGrade.value = ''
smartGroup.value = ''
smartInsurer.value = ''
smartLob.value = ''
smartAging.value = ''
smartCustomIds.value.clear()
showSmartAssign.value = false
}
function toggleSmartCustom(id: string) {
if (smartCustomIds.value.has(id)) smartCustomIds.value.delete(id)
else smartCustomIds.value.add(id)
}
const assignableAccounts = computed(() => {
let result = [...accounts.value]
if (assignSearch.value) {
const q = assignSearch.value.toLowerCase()
result = result.filter(a =>
a.customerName.toLowerCase().includes(q) ||
a.policyNumber.toLowerCase().includes(q) ||
a.id.toLowerCase().includes(q)
)
}
if (assignFilterStatus.value === 'unassigned') {
result = result.filter(a => !a.assignedAgent)
} else if (assignFilterStatus.value === 'assigned') {
result = result.filter(a => !!a.assignedAgent)
}
return result
})
function assignAgent(accountId: string, agent: string) {
const account = accounts.value.find(a => a.id === accountId)
if (account) {
account.assignedAgent = agent || null
}
}
const bulkSelected = ref<Set<string>>(new Set())
const bulkAssignAgent = ref('')
function toggleBulkSelect(id: string) {
if (bulkSelected.value.has(id)) {
bulkSelected.value.delete(id)
} else {
bulkSelected.value.add(id)
}
}
function toggleBulkAll() {
if (bulkSelected.value.size === assignableAccounts.value.length) {
bulkSelected.value.clear()
} else {
assignableAccounts.value.forEach(a => bulkSelected.value.add(a.id))
}
}
function applyBulkAssign() {
if (!bulkAssignAgent.value || bulkSelected.value.size === 0) return
bulkSelected.value.forEach(id => {
const account = accounts.value.find(a => a.id === id)
if (account) account.assignedAgent = bulkAssignAgent.value
})
bulkSelected.value.clear()
bulkAssignAgent.value = ''
}
</script>
<template>
<div class="col-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)]">Collections</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Premium collection tracking overdue balances, payment follow-up, assignment rules, and reconciliation.
</p>
</div>
</div>
<!-- KPI strip -->
<div class="col-kpi-strip">
<div class="col-kpi">
<p class="col-kpi-label">Overdue Accounts</p>
<p class="col-kpi-value" style="color: #c13838;">{{ kpis.totalAccounts }}</p>
</div>
<div class="col-kpi">
<p class="col-kpi-label">Total Overdue</p>
<p class="col-kpi-value">{{ formatCurrency(kpis.totalOverdue) }}</p>
</div>
<div class="col-kpi">
<p class="col-kpi-label">30-Day Overdue</p>
<p class="col-kpi-value" style="color: #c27b1a;">{{ kpis.thirtyDay }}</p>
</div>
<div class="col-kpi">
<p class="col-kpi-label">60-Day Overdue</p>
<p class="col-kpi-value" style="color: #e05a00;">{{ kpis.sixtyDay }}</p>
</div>
<div class="col-kpi">
<p class="col-kpi-label">90+ Day Overdue</p>
<p class="col-kpi-value" style="color: #c13838;">{{ kpis.ninetyPlus }}</p>
</div>
<div class="col-kpi">
<p class="col-kpi-label">Collection Rate</p>
<p class="col-kpi-value" style="color: #01696f;">{{ kpis.collectionRate }}%</p>
</div>
</div>
<!-- Main Tabs -->
<div class="col-main-tabs">
<button
type="button"
class="col-main-tab"
:class="activeTab === 'accounts' ? 'col-main-tab-active' : 'col-main-tab-inactive'"
@click="activeTab = 'accounts'"
>
<UIcon name="i-heroicons-exclamation-triangle" style="width:14px;height:14px;" />
Overdue Accounts
</button>
<button
type="button"
class="col-main-tab"
:class="activeTab === 'assigned_to_me' ? 'col-main-tab-active' : 'col-main-tab-inactive'"
@click="activeTab = 'assigned_to_me'"
>
<UIcon name="i-heroicons-inbox-arrow-down" style="width:14px;height:14px;" />
Assigned to Me
<span v-if="myAccounts.length" class="col-tab-count">{{ myAccounts.length }}</span>
</button>
<button
type="button"
class="col-main-tab"
:class="activeTab === 'my_lists' ? 'col-main-tab-active' : 'col-main-tab-inactive'"
@click="activeTab = 'my_lists'"
>
<UIcon name="i-heroicons-clipboard-document-list" style="width:14px;height:14px;" />
My Lists
</button>
<button
v-if="isManager"
type="button"
class="col-main-tab"
:class="activeTab === 'unassigned' ? 'col-main-tab-active' : 'col-main-tab-inactive'"
@click="activeTab = 'unassigned'"
>
<UIcon name="i-heroicons-user-plus" style="width:14px;height:14px;" />
Unassigned Accounts
</button>
<button
type="button"
class="col-main-tab"
:class="activeTab === 'assignment_rules' ? 'col-main-tab-active' : 'col-main-tab-inactive'"
@click="activeTab = 'assignment_rules'"
>
<UIcon name="i-heroicons-cog-6-tooth" style="width:14px;height:14px;" />
Account Assignment Rules
</button>
</div>
<!-- -->
<!-- OVERDUE ACCOUNTS TAB -->
<!-- -->
<template v-if="activeTab === 'accounts'">
<!-- Toolbar -->
<div class="col-toolbar">
<div class="col-toolbar-row">
<div class="col-search-wrap">
<UIcon name="i-heroicons-magnifying-glass" class="col-search-icon" />
<input
v-model="searchQuery"
type="text"
placeholder="Search by name, policy, insurer..."
class="col-search-input"
/>
</div>
<div class="col-toolbar-filters">
<select v-model="sortBy" class="col-select">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<select v-model="filterInsurer" class="col-select">
<option value="">All Insurers</option>
<option v-for="ins in insurers" :key="ins" :value="ins">{{ ins }}</option>
</select>
<select v-model="filterLob" class="col-select">
<option value="">All LOBs</option>
<option v-for="lob in lobOptions" :key="lob" :value="lob">{{ lob }}</option>
</select>
<select v-model="filterDaysRange" class="col-select">
<option value="">All Days</option>
<option v-for="dr in daysRangeOptions" :key="dr.value" :value="dr.value">{{ dr.label }}</option>
</select>
<select v-model="filterAgent" class="col-select">
<option value="">All Agents</option>
<option value="Unassigned">Unassigned</option>
<option v-for="ag in agents" :key="ag" :value="ag">{{ ag }}</option>
</select>
</div>
</div>
<div class="col-toolbar-meta">
<span class="text-[11px] text-[var(--text-muted)]">{{ filteredAccounts.length }} account{{ filteredAccounts.length !== 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Table -->
<div class="col-table-wrap">
<table class="col-table">
<thead>
<tr>
<th style="width:28px;"></th>
<th>Customer</th>
<th>Policy / LOB</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Total Premium</th>
<th class="text-right">Days</th>
<th>Grade</th>
<th>Collector</th>
<th>Status</th>
<th>Last Contact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template v-for="a in filteredAccounts" :key="a.id">
<!-- Main Row -->
<tr class="col-row" :class="{ 'col-row-expanded': expandedRows.has(a.id) }">
<td>
<button type="button" class="col-expand-btn" @click="toggleRow(a.id)">
<UIcon
:name="expandedRows.has(a.id) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
style="width:14px;height:14px;"
/>
</button>
</td>
<td>
<NuxtLink :to="`/customers/${a.customerId}`" class="col-customer-link">{{ a.customerName || 'Unnamed customer' }}</NuxtLink>
</td>
<td>
<NuxtLink :to="`/policies/${a.policyNumber}`" class="col-customer-link text-[12px] font-medium">{{ a.policyNumber }}</NuxtLink>
<p class="text-[11px] text-[var(--text-muted)]">{{ a.lob }}</p>
</td>
<td class="text-[13px] text-[var(--text-muted)]">{{ a.insurer }}</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(a.overdueAmount) }}</td>
<td class="text-right text-[12px] text-[var(--text-muted)]">{{ formatCurrency(a.totalPremium) }}</td>
<td class="text-right">
<span class="text-[13px] font-semibold" :class="agingColor(a.daysOverdue)">{{ a.daysOverdue }}d</span>
</td>
<td>
<span :class="['col-grade-badge', gradeMeta[a.grade].class]" :title="gradeMeta[a.grade].tooltip">{{ a.grade }}</span>
</td>
<td>
<span v-if="a.assignedAgent" class="text-[12px] text-[var(--text-primary)]">{{ a.assignedAgent }}</span>
<span v-else class="col-unassigned-badge">Unassigned</span>
</td>
<td>
<span :class="statusMeta[a.status].class">{{ statusMeta[a.status].label }}</span>
</td>
<td class="text-[12px] text-[var(--text-muted)]">{{ formatDate(a.lastContactDate) }}</td>
<td>
<div class="col-actions">
<select
class="col-action-select"
@change="updateAccountStatus(a.id, ($event.target as HTMLSelectElement).value as AccountStatus)"
>
<option v-for="s in allStatuses" :key="s" :value="s" :selected="s === a.status">{{ statusMeta[s].label }}</option>
</select>
<button type="button" class="col-icon-btn" title="Add note" @click="openNoteModal(a.id)">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width:14px;height:14px;" />
</button>
</div>
</td>
</tr>
<!-- Expanded Detail Row -->
<tr v-if="expandedRows.has(a.id)" :key="a.id + '-detail'" class="col-detail-row">
<td colspan="12">
<div class="col-detail-content">
<!-- Contact History -->
<div class="col-detail-section">
<h4 class="col-detail-title">Contact History & Notes</h4>
<div v-if="a.notes.length === 0" class="col-detail-empty">No contact history recorded.</div>
<div v-else class="col-notes-list">
<div v-for="(note, ni) in a.notes" :key="ni" class="col-note-item">
<div class="col-note-meta">
<span class="col-note-date">{{ formatDate(note.date) }}</span>
<span class="col-note-agent">{{ note.agent }}</span>
</div>
<p class="col-note-text">{{ note.note }}</p>
</div>
</div>
</div>
<!-- Payment Plan -->
<div v-if="a.paymentPlan" class="col-detail-section">
<h4 class="col-detail-title">Payment Plan</h4>
<div class="col-plan-card">
<div class="col-plan-row">
<span class="col-plan-label">Total Amount</span>
<span class="col-plan-value">{{ formatCurrency(a.paymentPlan.totalAmount) }}</span>
</div>
<div class="col-plan-row">
<span class="col-plan-label">Installments</span>
<span class="col-plan-value">{{ a.paymentPlan.installments }} payments of {{ formatCurrency(a.paymentPlan.amountPer) }}</span>
</div>
<div class="col-plan-row">
<span class="col-plan-label">Next Due</span>
<span class="col-plan-value">{{ formatDate(a.paymentPlan.nextDue) }}</span>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- -->
<!-- ASSIGNED TO ME TAB -->
<!-- -->
<template v-if="activeTab === 'assigned_to_me'">
<div class="col-card" style="padding: 16px 20px;">
<h3 class="text-[14px] font-semibold text-[var(--text-primary)]">Assigned to Me</h3>
<p class="text-[12px] text-[var(--text-muted)] mt-0.5">
Accounts assigned to you ({{ currentUser }}) for collection. {{ myAccounts.length }} active case{{ myAccounts.length !== 1 ? 's' : '' }}.
</p>
</div>
<div v-if="myAccounts.length === 0" class="col-card" style="padding: 40px 20px; text-align: center;">
<UIcon name="i-heroicons-inbox" style="width:36px;height:36px;color:#b0b0ab;margin:0 auto 12px;" />
<p class="text-[14px] font-medium text-[var(--text-primary)]">No accounts assigned</p>
<p class="text-[12px] text-[var(--text-muted)] mt-1">You currently have no collection accounts. Check with your manager or browse available accounts in My Lists.</p>
</div>
<div v-else class="col-table-wrap">
<table class="col-table">
<thead>
<tr>
<th style="width:28px;"></th>
<th>Customer</th>
<th>Policy / LOB</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Days</th>
<th>Grade</th>
<th>Status</th>
<th>Last Contact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template v-for="a in myAccounts" :key="a.id">
<tr class="col-row" :class="{ 'col-row-expanded': expandedRows.has(a.id) }">
<td>
<button type="button" class="col-expand-btn" @click="toggleRow(a.id)">
<UIcon
:name="expandedRows.has(a.id) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
style="width:14px;height:14px;"
/>
</button>
</td>
<td>
<NuxtLink :to="`/customers/${a.customerId}`" class="col-customer-link">{{ a.customerName || 'Unnamed customer' }}</NuxtLink>
<p v-if="a.economicGroup" class="text-[10px] text-[var(--text-muted)] mt-0.5">{{ a.economicGroup }}</p>
</td>
<td>
<NuxtLink :to="`/policies/${a.policyNumber}`" class="col-customer-link text-[12px] font-medium">{{ a.policyNumber }}</NuxtLink>
<p class="text-[11px] text-[var(--text-muted)]">{{ a.lob }}</p>
</td>
<td class="text-[13px] text-[var(--text-muted)]">{{ a.insurer }}</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(a.overdueAmount) }}</td>
<td class="text-right">
<span class="text-[13px] font-semibold" :class="agingColor(a.daysOverdue)">{{ a.daysOverdue }}d</span>
</td>
<td><span :class="['col-grade-badge', gradeMeta[a.grade].class]" :title="gradeMeta[a.grade].tooltip">{{ a.grade }}</span></td>
<td><span :class="statusMeta[a.status].class">{{ statusMeta[a.status].label }}</span></td>
<td class="text-[12px] text-[var(--text-muted)]">{{ formatDate(a.lastContactDate) }}</td>
<td>
<div class="col-actions">
<select
class="col-action-select"
@change="updateAccountStatus(a.id, ($event.target as HTMLSelectElement).value as AccountStatus)"
>
<option v-for="s in allStatuses" :key="s" :value="s" :selected="s === a.status">{{ statusMeta[s].label }}</option>
</select>
<button type="button" class="col-icon-btn" title="Add note" @click="openNoteModal(a.id)">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width:14px;height:14px;" />
</button>
</div>
</td>
</tr>
<tr v-if="expandedRows.has(a.id)" :key="a.id + '-detail'" class="col-detail-row">
<td colspan="10">
<div class="col-detail-content">
<div class="col-detail-section">
<h4 class="col-detail-title">Contact History & Notes</h4>
<div v-if="a.notes.length === 0" class="col-detail-empty">No contact history recorded.</div>
<div v-else class="col-notes-list">
<div v-for="(note, ni) in a.notes" :key="ni" class="col-note-item">
<div class="col-note-meta">
<span class="col-note-date">{{ formatDate(note.date) }}</span>
<span class="col-note-agent">{{ note.agent }}</span>
</div>
<p class="col-note-text">{{ note.note }}</p>
</div>
</div>
</div>
<div v-if="a.paymentPlan" class="col-detail-section">
<h4 class="col-detail-title">Payment Plan</h4>
<div class="col-plan-card">
<div class="col-plan-row"><span class="col-plan-label">Total Amount</span><span class="col-plan-value">{{ formatCurrency(a.paymentPlan.totalAmount) }}</span></div>
<div class="col-plan-row"><span class="col-plan-label">Installments</span><span class="col-plan-value">{{ a.paymentPlan.installments }} payments of {{ formatCurrency(a.paymentPlan.amountPer) }}</span></div>
<div class="col-plan-row"><span class="col-plan-label">Next Due</span><span class="col-plan-value">{{ formatDate(a.paymentPlan.nextDue) }}</span></div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- -->
<!-- MY LISTS TAB -->
<!-- -->
<template v-if="activeTab === 'my_lists'">
<!-- Create list bar -->
<div class="col-card" style="padding: 16px 20px;">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-[14px] font-semibold text-[var(--text-primary)]">Collection Lists</h3>
<p class="text-[12px] text-[var(--text-muted)] mt-0.5">
Build your own collection routes pick accounts and create a work list to take on the road.
</p>
</div>
<button v-if="!showCreateList" type="button" class="col-action-btn-primary" @click="showCreateList = true">
<UIcon name="i-heroicons-plus" style="width:14px;height:14px;" />
New List
</button>
</div>
</div>
<!-- Create list form -->
<div v-if="showCreateList" class="col-card" style="border-color: rgba(1,105,111,0.2); background: rgba(1,105,111,0.015);">
<h4 class="col-detail-title" style="margin-bottom:8px;">Create New List</h4>
<div class="flex flex-wrap items-end gap-3 mb-4">
<div class="col-form-group" style="flex:1;min-width:200px;">
<label class="col-form-label">List Name</label>
<input
v-model="newListName"
type="text"
class="col-search-input"
placeholder="e.g. Downtown route Apr 8"
/>
</div>
</div>
<!-- Available accounts to pick -->
<p class="col-form-label mb-2">Select accounts ({{ selectedForList.size }} selected)</p>
<div class="col-table-wrap" style="max-height: 360px; overflow-y: auto;">
<table class="col-table">
<thead>
<tr>
<th style="width:32px;">
<input
type="checkbox"
:checked="selectedForList.size === myAvailableAccounts.length && myAvailableAccounts.length > 0"
@change="selectAllAvailable"
class="col-checkbox"
/>
</th>
<th>Customer</th>
<th>Policy</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Days</th>
<th>Grade</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="a in myAvailableAccounts"
:key="a.id"
class="col-row"
:class="{ 'col-row-selected': selectedForList.has(a.id) }"
style="cursor:pointer;"
@click="toggleListSelect(a.id)"
>
<td>
<input
type="checkbox"
:checked="selectedForList.has(a.id)"
class="col-checkbox"
@click.stop="toggleListSelect(a.id)"
/>
</td>
<td class="text-[13px] font-medium text-[var(--text-primary)]">{{ a.customerName }}</td>
<td>
<NuxtLink :to="`/policies/${a.policyNumber}`" class="col-customer-link text-[12px]">{{ a.policyNumber }}</NuxtLink>
<p class="text-[11px] text-[var(--text-muted)]">{{ a.lob }}</p>
</td>
<td class="text-[13px] text-[var(--text-muted)]">{{ a.insurer }}</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(a.overdueAmount) }}</td>
<td class="text-right">
<span class="text-[13px] font-semibold" :class="agingColor(a.daysOverdue)">{{ a.daysOverdue }}d</span>
</td>
<td><span :class="['col-grade-badge', gradeMeta[a.grade].class]" :title="gradeMeta[a.grade].tooltip">{{ a.grade }}</span></td>
<td><span :class="statusMeta[a.status].class">{{ statusMeta[a.status].label }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="col-add-rule-actions" style="margin-top:12px;">
<button type="button" class="col-btn-secondary" @click="showCreateList = false; selectedForList.clear()">Cancel</button>
<button
type="button"
class="col-action-btn-primary"
:disabled="!newListName.trim() || selectedForList.size === 0"
@click="createList"
>
Create List ({{ selectedForList.size }} accounts)
</button>
</div>
</div>
<!-- Saved lists -->
<div v-if="myLists.length === 0 && !showCreateList" class="col-card" style="padding: 40px 20px; text-align: center;">
<UIcon name="i-heroicons-clipboard-document-list" style="width:36px;height:36px;color:#b0b0ab;margin:0 auto 12px;" />
<p class="text-[14px] font-medium text-[var(--text-primary)]">No lists yet</p>
<p class="text-[12px] text-[var(--text-muted)] mt-1">Create a collection list to organize your route and track which accounts to visit.</p>
</div>
<div v-for="list in myLists" :key="list.id" class="col-card" style="padding:0;overflow:hidden;">
<!-- List header -->
<div
class="col-list-header"
@click="toggleListExpand(list.id)"
>
<div class="flex items-center gap-3">
<button type="button" class="col-expand-btn">
<UIcon
:name="expandedList === list.id ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
style="width:14px;height:14px;"
/>
</button>
<div>
<p class="text-[14px] font-semibold text-[var(--text-primary)]">{{ list.name }}</p>
<p class="text-[11px] text-[var(--text-muted)]">
{{ list.accountIds.length }} account{{ list.accountIds.length !== 1 ? 's' : '' }} · Created {{ formatDate(list.createdAt) }}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="col-list-total">{{ formatCurrency(list.accountIds.reduce((s, id) => s + (getAccountById(id)?.overdueAmount ?? 0), 0)) }}</span>
<button
type="button"
class="col-icon-btn col-icon-btn-danger"
title="Delete list"
@click.stop="deleteList(list.id)"
>
<UIcon name="i-heroicons-trash" style="width:14px;height:14px;" />
</button>
</div>
</div>
<!-- List accounts -->
<div v-if="expandedList === list.id" class="col-list-body">
<table class="col-table">
<thead>
<tr>
<th>Customer</th>
<th>Policy</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Days</th>
<th>Grade</th>
<th>Status</th>
<th>Last Contact</th>
</tr>
</thead>
<tbody>
<tr v-for="aid in list.accountIds" :key="aid" class="col-row">
<template v-if="getAccountById(aid)">
<td>
<NuxtLink :to="`/customers/${getAccountById(aid)!.customerId}`" class="col-customer-link">
{{ getAccountById(aid)!.customerName }}
</NuxtLink>
</td>
<td>
<NuxtLink :to="`/policies/${getAccountById(aid)!.policyNumber}`" class="col-customer-link text-[12px] font-medium">{{ getAccountById(aid)!.policyNumber }}</NuxtLink>
<p class="text-[11px] text-[var(--text-muted)]">{{ getAccountById(aid)!.lob }}</p>
</td>
<td class="text-[13px] text-[var(--text-muted)]">{{ getAccountById(aid)!.insurer }}</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(getAccountById(aid)!.overdueAmount) }}</td>
<td class="text-right">
<span class="text-[13px] font-semibold" :class="agingColor(getAccountById(aid)!.daysOverdue)">{{ getAccountById(aid)!.daysOverdue }}d</span>
</td>
<td><span :class="['col-grade-badge', gradeMeta[getAccountById(aid)!.grade].class]">{{ getAccountById(aid)!.grade }}</span></td>
<td><span :class="statusMeta[getAccountById(aid)!.status].class">{{ statusMeta[getAccountById(aid)!.status].label }}</span></td>
<td class="text-[12px] text-[var(--text-muted)]">{{ formatDate(getAccountById(aid)!.lastContactDate) }}</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- -->
<!-- UNASSIGNED ACCOUNTS TAB (manager only) -->
<!-- -->
<template v-if="activeTab === 'unassigned' && isManager">
<div class="col-card" style="padding: 16px 20px;">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-[14px] font-semibold text-[var(--text-primary)]">Unassigned Accounts</h3>
<p class="text-[12px] text-[var(--text-muted)] mt-0.5">
Accounts awaiting assignment. Use Smart Assign to assign by criteria, or assign individually.
</p>
</div>
<button v-if="!showSmartAssign" type="button" class="col-action-btn-primary" @click="showSmartAssign = true">
<UIcon name="i-heroicons-bolt" style="width:14px;height:14px;" />
Smart Assign
</button>
</div>
</div>
<!-- Smart Assign Panel -->
<div v-if="showSmartAssign" class="col-card" style="border-color: rgba(1,105,111,0.2); background: rgba(1,105,111,0.015);">
<h4 class="col-detail-title" style="margin-bottom: 12px;">Smart Assign Assign by Criteria</h4>
<!-- Mode pills -->
<div class="col-smart-modes">
<button
v-for="m in ([
{ id: 'grade' as SmartAssignMode, label: 'Account Grade', icon: 'i-heroicons-chart-bar' },
{ id: 'group' as SmartAssignMode, label: 'Economic Group', icon: 'i-heroicons-building-office' },
{ id: 'insurer' as SmartAssignMode, label: 'Insurer', icon: 'i-heroicons-building-office-2' },
{ id: 'lob' as SmartAssignMode, label: 'Policy Type', icon: 'i-heroicons-document-text' },
{ id: 'insurer_lob' as SmartAssignMode, label: 'Insurer + LOB', icon: 'i-heroicons-squares-2x2' },
{ id: 'aging' as SmartAssignMode, label: 'Days Overdue', icon: 'i-heroicons-clock' },
{ id: 'custom' as SmartAssignMode, label: 'Custom Pick', icon: 'i-heroicons-cursor-arrow-rays' },
])"
:key="m.id"
type="button"
class="col-smart-mode-pill"
:class="smartMode === m.id ? 'col-smart-mode-active' : ''"
@click="smartMode = m.id"
>
<UIcon :name="m.icon" style="width:13px;height:13px;" />
{{ m.label }}
</button>
</div>
<!-- Criteria selectors -->
<div class="col-smart-criteria">
<!-- Grade -->
<div v-if="smartMode === 'grade'" class="col-smart-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Grade</label>
<select v-model="smartGrade" class="col-select col-select-full">
<option value="">Select grade...</option>
<option value="A">A — 029 days</option>
<option value="B">B — 3059 days</option>
<option value="C">C — 6089 days</option>
<option value="D">D — 90+ days</option>
</select>
</div>
</div>
<!-- Economic Group -->
<div v-if="smartMode === 'group'" class="col-smart-row">
<div class="col-form-group" style="min-width:200px;">
<label class="col-form-label">Economic Group / Colectivo</label>
<select v-model="smartGroup" class="col-select col-select-full">
<option value="">Select group...</option>
<option v-for="g in economicGroups" :key="g" :value="g">{{ g }}</option>
</select>
</div>
</div>
<!-- Insurer -->
<div v-if="smartMode === 'insurer'" class="col-smart-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Insurer</label>
<select v-model="smartInsurer" class="col-select col-select-full">
<option value="">Select insurer...</option>
<option v-for="ins in insurers" :key="ins" :value="ins">{{ ins }}</option>
</select>
</div>
</div>
<!-- LOB -->
<div v-if="smartMode === 'lob'" class="col-smart-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Policy Type (LOB)</label>
<select v-model="smartLob" class="col-select col-select-full">
<option value="">Select type...</option>
<option v-for="lob in lobOptions" :key="lob" :value="lob">{{ lob }}</option>
</select>
</div>
</div>
<!-- Insurer + LOB -->
<div v-if="smartMode === 'insurer_lob'" class="col-smart-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Insurer</label>
<select v-model="smartInsurer" class="col-select col-select-full">
<option value="">Select insurer...</option>
<option v-for="ins in insurers" :key="ins" :value="ins">{{ ins }}</option>
</select>
</div>
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Policy Type (LOB)</label>
<select v-model="smartLob" class="col-select col-select-full">
<option value="">Select type...</option>
<option v-for="lob in lobOptions" :key="lob" :value="lob">{{ lob }}</option>
</select>
</div>
</div>
<!-- Aging -->
<div v-if="smartMode === 'aging'" class="col-smart-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Minimum Days Overdue</label>
<select v-model="smartAging" class="col-select col-select-full">
<option value="">Select range...</option>
<option v-for="opt in agingOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<!-- Custom Pick -->
<div v-if="smartMode === 'custom'">
<p class="text-[12px] text-[var(--text-muted)] mb-2">Click accounts to select them for assignment.</p>
<div class="col-table-wrap" style="max-height:240px;overflow-y:auto;">
<table class="col-table">
<thead>
<tr>
<th style="width:32px;"></th>
<th>Customer</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Days</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<tr
v-for="a in accounts"
:key="a.id"
class="col-row"
:class="{ 'col-row-selected': smartCustomIds.has(a.id) }"
style="cursor:pointer;"
@click="toggleSmartCustom(a.id)"
>
<td><input type="checkbox" :checked="smartCustomIds.has(a.id)" class="col-checkbox" @click.stop="toggleSmartCustom(a.id)" /></td>
<td class="text-[13px] text-[var(--text-primary)]">{{ a.customerName }}</td>
<td class="text-[12px] text-[var(--text-muted)]">{{ a.insurer }}</td>
<td class="text-right text-[13px] font-semibold">{{ formatCurrency(a.overdueAmount) }}</td>
<td class="text-right"><span class="text-[13px] font-semibold" :class="agingColor(a.daysOverdue)">{{ a.daysOverdue }}d</span></td>
<td><span :class="['col-grade-badge', gradeMeta[a.grade].class]" :title="gradeMeta[a.grade].tooltip">{{ a.grade }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Agent + preview + action -->
<div class="col-smart-action-row">
<div class="col-form-group" style="min-width:160px;">
<label class="col-form-label">Assign To</label>
<select v-model="smartAgent" class="col-select col-select-full">
<option value="">Select agent...</option>
<option v-for="ag in agents" :key="ag" :value="ag">{{ ag }}</option>
</select>
</div>
<div class="col-smart-preview">
<span v-if="smartMatchCount > 0" class="text-[13px] font-medium text-[#01696f]">
{{ smartMatchCount }} account{{ smartMatchCount !== 1 ? 's' : '' }} match
</span>
<span v-else class="text-[12px] text-[var(--text-muted)]">No accounts match criteria</span>
</div>
<div class="flex gap-2">
<button type="button" class="col-btn-secondary" @click="showSmartAssign = false">Cancel</button>
<button
type="button"
class="col-action-btn-primary"
:disabled="!smartAgent || smartMatchCount === 0"
@click="applySmartAssign"
>
Assign {{ smartMatchCount }} Account{{ smartMatchCount !== 1 ? 's' : '' }}
</button>
</div>
</div>
</div>
</div>
<!-- Bulk action bar -->
<div v-if="bulkSelected.size > 0" class="col-bulk-bar">
<span class="text-[13px] font-medium text-[var(--text-primary)]">{{ bulkSelected.size }} account{{ bulkSelected.size !== 1 ? 's' : '' }} selected</span>
<div class="flex items-center gap-2">
<select v-model="bulkAssignAgent" class="col-select">
<option value="">Assign to...</option>
<option v-for="ag in agents" :key="ag" :value="ag">{{ ag }}</option>
</select>
<button
type="button"
class="col-action-btn-primary"
:disabled="!bulkAssignAgent"
@click="applyBulkAssign"
style="padding: 6px 14px; font-size: 12px;"
>
Assign
</button>
<button type="button" class="col-btn-secondary" @click="bulkSelected.clear()" style="padding: 6px 14px; font-size: 12px;">
Clear
</button>
</div>
</div>
<!-- Toolbar -->
<div class="col-toolbar">
<div class="col-toolbar-row">
<div class="col-search-wrap">
<UIcon name="i-heroicons-magnifying-glass" class="col-search-icon" />
<input
v-model="assignSearch"
type="text"
placeholder="Search accounts..."
class="col-search-input"
/>
</div>
<select v-model="assignFilterStatus" class="col-select">
<option value="">All Accounts</option>
<option value="unassigned">Unassigned Only</option>
<option value="assigned">Assigned Only</option>
</select>
</div>
<div class="col-toolbar-meta">
<span class="text-[11px] text-[var(--text-muted)]">{{ assignableAccounts.length }} account{{ assignableAccounts.length !== 1 ? 's' : '' }}</span>
</div>
</div>
<!-- Assign table -->
<div class="col-table-wrap">
<table class="col-table">
<thead>
<tr>
<th style="width:32px;">
<input
type="checkbox"
:checked="bulkSelected.size === assignableAccounts.length && assignableAccounts.length > 0"
@change="toggleBulkAll"
class="col-checkbox"
/>
</th>
<th>Customer</th>
<th>Policy / LOB</th>
<th>Insurer</th>
<th class="text-right">Overdue</th>
<th class="text-right">Days</th>
<th>Grade</th>
<th>Status</th>
<th>Assigned To</th>
</tr>
</thead>
<tbody>
<tr
v-for="a in assignableAccounts"
:key="a.id"
class="col-row"
:class="{ 'col-row-selected': bulkSelected.has(a.id) }"
>
<td>
<input
type="checkbox"
:checked="bulkSelected.has(a.id)"
@change="toggleBulkSelect(a.id)"
class="col-checkbox"
/>
</td>
<td>
<NuxtLink :to="`/customers/${a.customerId}`" class="col-customer-link">{{ a.customerName || 'Unnamed customer' }}</NuxtLink>
</td>
<td>
<NuxtLink :to="`/policies/${a.policyNumber}`" class="col-customer-link text-[12px] font-medium">{{ a.policyNumber }}</NuxtLink>
<p class="text-[11px] text-[var(--text-muted)]">{{ a.lob }}</p>
</td>
<td class="text-[13px] text-[var(--text-muted)]">{{ a.insurer }}</td>
<td class="text-right text-[13px] font-semibold text-[var(--text-primary)]">{{ formatCurrency(a.overdueAmount) }}</td>
<td class="text-right">
<span class="text-[13px] font-semibold" :class="agingColor(a.daysOverdue)">{{ a.daysOverdue }}d</span>
</td>
<td><span :class="['col-grade-badge', gradeMeta[a.grade].class]" :title="gradeMeta[a.grade].tooltip">{{ a.grade }}</span></td>
<td><span :class="statusMeta[a.status].class">{{ statusMeta[a.status].label }}</span></td>
<td>
<div class="flex items-center gap-2">
<select
class="col-assign-select"
@change="assignAgent(a.id, ($event.target as HTMLSelectElement).value)"
>
<option value="" :selected="!a.assignedAgent">Unassigned</option>
<option v-for="ag in agents" :key="ag" :value="ag" :selected="ag === a.assignedAgent">{{ ag }}</option>
</select>
<button
v-if="a.assignedAgent"
type="button"
class="col-icon-btn col-icon-btn-danger"
title="Unassign"
@click="assignAgent(a.id, '')"
>
<UIcon name="i-heroicons-x-mark" style="width:12px;height:12px;" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- ════════════════════════════════════════════ -->
<!-- ACCOUNT ASSIGNMENT RULES TAB -->
<!-- ════════════════════════════════════════════ -->
<template v-if="activeTab === 'assignment_rules'">
<!-- Config sub-tabs -->
<div class="col-config-tabs">
<button
v-for="sec in ([
{ id: 'upload' as ConfigSection, label: 'Upload Payment Reports', icon: 'i-heroicons-arrow-up-tray' },
{ id: 'rules' as ConfigSection, label: 'Assignment Rules', icon: 'i-heroicons-user-group' },
{ id: 'settings' as ConfigSection, label: 'Collection Settings', icon: 'i-heroicons-adjustments-horizontal' },
])"
:key="sec.id"
type="button"
class="col-config-tab"
:class="activeConfigSection === sec.id ? 'col-config-tab-active' : 'col-config-tab-inactive'"
@click="activeConfigSection = sec.id"
>
<UIcon :name="sec.icon" style="width:14px;height:14px;" />
{{ sec.label }}
</button>
</div>
<!-- ── Upload Payment Reports ── -->
<div v-if="activeConfigSection === 'upload'" class="col-config-content">
<div class="col-card">
<h3 class="col-card-title">Upload Insurer Payment Report</h3>
<p class="col-card-desc">
Upload insurer payment reports to reconcile accounts. Policies included in the report are marked as paid.
Policies NOT on the report that have outstanding balances remain overdue.
</p>
<!-- Drop zone -->
<div
class="col-dropzone"
:class="{ 'col-dropzone-active': isDragOver }"
@dragenter.prevent="isDragOver = true"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop"
>
<UIcon name="i-heroicons-cloud-arrow-up" class="col-dropzone-icon" />
<p class="col-dropzone-text">Drag & drop your payment report here</p>
<p class="col-dropzone-hint">Accepts .xls, .xlsx, .csv files</p>
<button type="button" class="col-dropzone-btn" @click="handleFileSelect">
Or click to browse files
</button>
</div>
<!-- Upload success summary -->
<div v-if="uploadComplete" class="col-upload-result">
<UIcon name="i-heroicons-check-circle" style="width:20px;height:20px;color:#16a34a;" />
<div>
<p class="col-upload-result-title">Report processed successfully</p>
<p class="col-upload-result-detail">
{{ uploadSummary.policiesPaid }} policies marked as paid, {{ uploadSummary.newOverdue }} new overdue accounts detected.
</p>
</div>
</div>
</div>
<!-- Upload history -->
<div class="col-card">
<h3 class="col-card-title">Upload History</h3>
<div class="col-upload-history">
<div v-for="rec in uploadHistory" :key="rec.id" class="col-upload-history-item">
<div class="col-upload-history-left">
<UIcon name="i-heroicons-document-text" style="width:16px;height:16px;color:#8a8a86;" />
<div>
<p class="col-upload-filename">{{ rec.filename }}</p>
<p class="col-upload-date">{{ formatDate(rec.date) }}</p>
</div>
</div>
<div class="col-upload-history-right">
<span class="col-upload-stat col-upload-stat-paid">{{ rec.policiesPaid }} paid</span>
<span class="col-upload-stat col-upload-stat-new">{{ rec.newOverdue }} new overdue</span>
</div>
</div>
</div>
</div>
</div>
<!-- ── Assignment Rules ── -->
<div v-if="activeConfigSection === 'rules'" class="col-config-content">
<div class="col-card">
<div class="flex items-center justify-between gap-3 mb-4">
<div>
<h3 class="col-card-title" style="margin-bottom:0;">Assignment Rules</h3>
<p class="col-card-desc" style="margin-top:4px;">
Define automatic collector assignment based on insurer, policy type, grade, or specific accounts.
</p>
</div>
<button type="button" class="col-action-btn-primary" @click="showAddRule = true">
<UIcon name="i-heroicons-plus" style="width:14px;height:14px;" />
Add Rule
</button>
</div>
<!-- Rules table -->
<div class="col-rules-table-wrap">
<table class="col-table col-rules-table">
<thead>
<tr>
<th>Condition</th>
<th>Assigned Agent</th>
<th>Enabled</th>
<th style="width:60px;">Remove</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in assignmentRules" :key="rule.id" class="col-row">
<td>
<span class="text-[13px] text-[var(--text-primary)]">{{ describeRule(rule) }}</span>
</td>
<td>
<span class="text-[13px] font-medium text-[var(--text-primary)]">{{ rule.assignedAgent }}</span>
</td>
<td>
<USwitch v-model="rule.enabled" />
</td>
<td>
<button type="button" class="col-icon-btn col-icon-btn-danger" @click="removeRule(rule.id)">
<UIcon name="i-heroicons-trash" style="width:14px;height:14px;" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Rule Form -->
<div v-if="showAddRule" class="col-add-rule-form">
<h4 class="col-detail-title" style="margin-bottom:12px;">New Assignment Rule</h4>
<div class="col-add-rule-grid">
<div class="col-form-group">
<label class="col-form-label">Insurer (optional)</label>
<select v-model="newRule.insurer" class="col-select col-select-full">
<option :value="null">All Insurers</option>
<option v-for="ins in insurers" :key="ins" :value="ins">{{ ins }}</option>
</select>
</div>
<div class="col-form-group">
<label class="col-form-label">Policy Type (optional)</label>
<select v-model="newRule.policyType" class="col-select col-select-full">
<option :value="null">All Types</option>
<option v-for="lob in lobOptions" :key="lob" :value="lob">{{ lob }}</option>
</select>
</div>
<div class="col-form-group">
<label class="col-form-label">Grade (optional)</label>
<select v-model="newRule.grade" class="col-select col-select-full">
<option :value="null">All Grades</option>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
</select>
</div>
<div class="col-form-group">
<label class="col-form-label">Assigned Agent</label>
<select v-model="newRule.assignedAgent" class="col-select col-select-full">
<option value="">Select agent...</option>
<option v-for="ag in agents" :key="ag" :value="ag">{{ ag }}</option>
</select>
</div>
</div>
<div class="col-add-rule-actions">
<button type="button" class="col-btn-secondary" @click="showAddRule = false">Cancel</button>
<button type="button" class="col-action-btn-primary" :disabled="!newRule.assignedAgent" @click="addRule">Save Rule</button>
</div>
</div>
</div>
</div>
<!-- ── Collection Settings ── -->
<div v-if="activeConfigSection === 'settings'" class="col-config-content">
<div class="col-card">
<h3 class="col-card-title">Collection Settings</h3>
<p class="col-card-desc">Configure automated collection behaviors and notifications.</p>
<div class="col-settings-list">
<div class="col-setting-item">
<div class="col-setting-info">
<p class="col-setting-name">Auto-escalate overdue accounts</p>
<p class="col-setting-desc">Automatically escalate accounts that exceed the escalation threshold.</p>
</div>
<USwitch v-model="settingsAutoEscalate" />
</div>
<div class="col-setting-item">
<div class="col-setting-info">
<p class="col-setting-name">Send reminder emails to customers</p>
<p class="col-setting-desc">Automatically send payment reminder emails to overdue customers.</p>
</div>
<USwitch v-model="settingsSendReminders" />
</div>
<div class="col-setting-item">
<div class="col-setting-info">
<p class="col-setting-name">Notify assigned agent on status change</p>
<p class="col-setting-desc">Send a notification to the assigned collector when an account status changes.</p>
</div>
<USwitch v-model="settingsNotifyAgent" />
</div>
<div class="col-setting-item">
<div class="col-setting-info">
<p class="col-setting-name">Default escalation threshold</p>
<p class="col-setting-desc">Number of days overdue before automatic escalation triggers.</p>
</div>
<select v-model="settingsEscalationThreshold" class="col-select">
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</div>
</div>
</div>
</div>
</template>
<!-- ════════════════════════════════════════════ -->
<!-- ADD NOTE MODAL -->
<!-- ════════════════════════════════════════════ -->
<Teleport to="body">
<div v-if="noteModal.open" class="col-modal-overlay" @click.self="noteModal.open = false">
<div class="col-modal">
<div class="col-modal-header">
<h3 class="col-modal-title">Add Note</h3>
<button type="button" class="col-icon-btn" @click="noteModal.open = false">
<UIcon name="i-heroicons-x-mark" style="width:16px;height:16px;" />
</button>
</div>
<div class="col-modal-body">
<label class="col-form-label">Note</label>
<textarea
v-model="noteModal.text"
class="col-textarea"
rows="4"
placeholder="Enter contact note or update..."
></textarea>
</div>
<div class="col-modal-footer">
<button type="button" class="col-btn-secondary" @click="noteModal.open = false">Cancel</button>
<button type="button" class="col-action-btn-primary" :disabled="!noteModal.text.trim()" @click="saveNote">Save Note</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
/* ── Page layout ── */
.col-page {
max-width: 76rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
padding-bottom: 3rem;
}
/* ── KPI strip ── */
.col-kpi-strip {
display: grid;
grid-template-columns: repeat(6, 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;
}
.col-kpi {
padding: 14px 18px;
background: #fff;
}
.col-kpi:first-child { border-radius: 12px 0 0 12px; }
.col-kpi:last-child { border-radius: 0 12px 12px 0; }
.col-kpi-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
}
.col-kpi-value {
margin-top: 4px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
@media (max-width: 900px) {
.col-kpi-strip { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.col-kpi-strip { grid-template-columns: repeat(2, 1fr); }
}
/* ── Main tabs ── */
.col-main-tabs {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(0,0,0,0.04);
}
.col-main-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.col-main-tab-active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.col-main-tab-inactive {
background: transparent;
color: var(--text-muted);
}
.col-main-tab-inactive:hover {
color: var(--text-primary);
}
/* ── Toolbar ── */
.col-toolbar {
display: flex;
flex-direction: column;
gap: 8px;
}
.col-toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.col-toolbar-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.col-toolbar-meta {
display: flex;
align-items: center;
}
.col-search-wrap {
position: relative;
flex: 1;
min-width: 200px;
max-width: 320px;
}
.col-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: #8a8a86;
pointer-events: none;
}
.col-search-input {
width: 100%;
padding: 7px 10px 7px 30px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 13px;
background: #fff;
color: var(--text-primary);
outline: none;
transition: border-color 150ms ease;
}
.col-search-input:focus {
border-color: #01696f;
}
.col-search-input::placeholder {
color: #b0b0ab;
}
.col-select {
padding: 5px 10px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.08);
font-size: 12px;
font-weight: 500;
background: #fff;
color: var(--text-primary);
cursor: pointer;
outline: none;
min-width: 100px;
appearance: auto;
}
.col-select:focus {
border-color: #01696f;
}
.col-select-full {
width: 100%;
}
/* ── Table ── */
.col-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;
}
.col-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.col-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;
}
.col-table tbody td {
padding: 12px 14px;
border-bottom: 1px solid rgba(0,0,0,0.04);
vertical-align: middle;
}
.col-row {
transition: background 100ms ease;
}
.col-row:hover {
background: rgba(0,0,0,0.015);
}
.col-row:last-child td {
border-bottom: none;
}
.col-row-expanded {
background: rgba(1,105,111,0.02);
}
/* ── Expand button ── */
.col-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
border: none;
background: transparent;
color: #8a8a86;
cursor: pointer;
transition: all 150ms ease;
}
.col-expand-btn:hover {
background: rgba(0,0,0,0.06);
color: var(--text-primary);
}
/* ── Customer link ── */
.col-customer-link {
font-size: 13px;
font-weight: 500;
color: #01696f;
text-decoration: none;
transition: color 150ms ease;
}
.col-customer-link:hover {
color: #015458;
text-decoration: underline;
}
/* ── Aging colors ── */
.col-aging-critical { color: #c13838; }
.col-aging-high { color: #e05a00; }
.col-aging-medium { color: #c27b1a; }
.col-aging-low { color: var(--text-primary); }
/* ── Grade badges ── */
.col-grade-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
}
.col-grade-a { background: rgba(22,163,74,0.08); color: #16a34a; }
.col-grade-b { background: rgba(194,123,26,0.08); color: #c27b1a; }
.col-grade-c { background: rgba(224,90,0,0.08); color: #e05a00; }
.col-grade-d { background: rgba(193,56,56,0.08); color: #c13838; }
/* ── Status badges ── */
.col-st-pending {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(0,0,0,0.05); color: #8a8a86; white-space: nowrap;
}
.col-st-contacted {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(59,130,246,0.08); color: #3b82f6; white-space: nowrap;
}
.col-st-plan {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
}
.col-st-escalated {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(193,56,56,0.08); color: #c13838; white-space: nowrap;
}
.col-st-resolved {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(22,163,74,0.08); color: #16a34a; white-space: nowrap;
}
/* ── Unassigned badge ── */
.col-unassigned-badge {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap;
}
/* ── Actions ── */
.col-actions {
display: flex;
align-items: center;
gap: 4px;
}
.col-action-select {
padding: 4px 6px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 11px;
background: #fff;
color: var(--text-primary);
cursor: pointer;
outline: none;
}
.col-icon-btn {
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;
}
.col-icon-btn:hover {
background: rgba(0,0,0,0.06);
color: var(--text-primary);
}
.col-icon-btn-danger:hover {
background: rgba(193,56,56,0.08);
color: #c13838;
}
/* ── Detail row ── */
.col-detail-row td {
padding: 0 !important;
border-bottom: 1px solid rgba(0,0,0,0.06) !important;
}
.col-detail-content {
padding: 16px 20px 20px 56px;
display: flex;
gap: 32px;
flex-wrap: wrap;
background: rgba(1,105,111,0.015);
}
.col-detail-section {
flex: 1;
min-width: 280px;
}
.col-detail-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
margin-bottom: 10px;
}
.col-detail-empty {
font-size: 12px;
color: #b0b0ab;
font-style: italic;
}
/* ── Notes list ── */
.col-notes-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.col-note-item {
padding: 10px 12px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(0,0,0,0.05);
}
.col-note-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.col-note-date {
font-size: 11px;
font-weight: 600;
color: #8a8a86;
}
.col-note-agent {
font-size: 11px;
font-weight: 500;
color: #01696f;
}
.col-note-text {
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
}
/* ── Payment Plan Card ── */
.col-plan-card {
padding: 12px 14px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 8px;
}
.col-plan-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.col-plan-label {
font-size: 12px;
color: #8a8a86;
}
.col-plan-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
/* ── Config tabs ── */
.col-config-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.col-config-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.col-config-tab-active {
background: #fff;
color: #01696f;
border-color: rgba(1,105,111,0.2);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.col-config-tab-inactive {
background: transparent;
color: var(--text-muted);
}
.col-config-tab-inactive:hover {
background: rgba(0,0,0,0.03);
color: var(--text-primary);
}
.col-config-content {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Card ── */
.col-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);
padding: 20px 24px;
}
.col-card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.col-card-desc {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 16px;
}
/* ── Drop zone ── */
.col-dropzone {
border: 2px dashed rgba(0,0,0,0.12);
border-radius: 12px;
padding: 36px 24px;
text-align: center;
transition: all 200ms ease;
cursor: pointer;
}
.col-dropzone-active {
border-color: #01696f;
background: rgba(1,105,111,0.03);
}
.col-dropzone:hover {
border-color: rgba(0,0,0,0.2);
}
.col-dropzone-icon {
width: 36px;
height: 36px;
color: #b0b0ab;
margin: 0 auto 12px;
}
.col-dropzone-text {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.col-dropzone-hint {
font-size: 12px;
color: #8a8a86;
margin-bottom: 12px;
}
.col-dropzone-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.12);
background: #fff;
font-size: 12px;
font-weight: 500;
color: #01696f;
cursor: pointer;
transition: all 150ms ease;
}
.col-dropzone-btn:hover {
background: rgba(1,105,111,0.04);
border-color: #01696f;
}
/* ── Upload result ── */
.col-upload-result {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 16px;
padding: 12px 14px;
border-radius: 8px;
background: rgba(22,163,74,0.05);
border: 1px solid rgba(22,163,74,0.15);
}
.col-upload-result-title {
font-size: 13px;
font-weight: 600;
color: #16a34a;
}
.col-upload-result-detail {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Upload history ── */
.col-upload-history {
display: flex;
flex-direction: column;
gap: 8px;
}
.col-upload-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.04);
transition: background 100ms ease;
}
.col-upload-history-item:hover {
background: rgba(0,0,0,0.015);
}
.col-upload-history-left {
display: flex;
align-items: center;
gap: 10px;
}
.col-upload-filename {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.col-upload-date {
font-size: 11px;
color: #8a8a86;
}
.col-upload-history-right {
display: flex;
gap: 8px;
}
.col-upload-stat {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 9999px;
white-space: nowrap;
}
.col-upload-stat-paid {
background: rgba(22,163,74,0.08);
color: #16a34a;
}
.col-upload-stat-new {
background: rgba(193,56,56,0.08);
color: #c13838;
}
/* ── Rules table ── */
.col-rules-table-wrap {
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.06);
overflow: hidden;
}
.col-rules-table tbody td {
vertical-align: middle;
}
/* ── Add Rule Form ── */
.col-add-rule-form {
margin-top: 16px;
padding: 16px 20px;
border-radius: 10px;
border: 1px solid rgba(1,105,111,0.15);
background: rgba(1,105,111,0.02);
}
.col-add-rule-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (max-width: 640px) {
.col-add-rule-grid { grid-template-columns: 1fr; }
}
.col-add-rule-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.col-form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.col-form-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
}
/* ── Settings list ── */
.col-settings-list {
display: flex;
flex-direction: column;
gap: 0;
}
.col-setting-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.col-setting-item:last-child {
border-bottom: none;
}
.col-setting-info {
flex: 1;
}
.col-setting-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.col-setting-desc {
font-size: 12px;
color: #8a8a86;
margin-top: 2px;
}
/* ── Buttons ── */
.col-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;
}
.col-action-btn-primary:hover {
background: #015458;
}
.col-action-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.col-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
background: #fff;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
border: 1px solid rgba(0,0,0,0.12);
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.col-btn-secondary:hover {
background: rgba(0,0,0,0.03);
}
/* ── Modal ── */
.col-modal-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(2px);
}
.col-modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
width: 100%;
max-width: 480px;
margin: 16px;
overflow: hidden;
}
.col-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.col-modal-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.col-modal-body {
padding: 16px 20px;
}
.col-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid rgba(0,0,0,0.06);
}
.col-textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 13px;
font-family: inherit;
color: var(--text-primary);
background: #fff;
outline: none;
resize: vertical;
margin-top: 6px;
transition: border-color 150ms ease;
}
.col-textarea:focus {
border-color: #01696f;
}
.col-textarea::placeholder {
color: #b0b0ab;
}
/* ── Checkbox ── */
.col-checkbox {
width: 15px;
height: 15px;
accent-color: #01696f;
cursor: pointer;
}
/* ── Selected row ── */
.col-row-selected {
background: rgba(1,105,111,0.04) !important;
}
/* ── My Lists ── */
.col-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
cursor: pointer;
transition: background 100ms ease;
}
.col-list-header:hover {
background: rgba(0,0,0,0.015);
}
.col-list-total {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.col-list-body {
border-top: 1px solid rgba(0,0,0,0.06);
}
/* ── Bulk action bar ── */
.col-bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-radius: 10px;
background: rgba(1,105,111,0.04);
border: 1px solid rgba(1,105,111,0.15);
}
/* ── Assign select ── */
.col-assign-select {
padding: 5px 8px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.1);
font-size: 12px;
background: #fff;
color: var(--text-primary);
cursor: pointer;
outline: none;
min-width: 130px;
}
.col-assign-select:focus {
border-color: #01696f;
}
/* ── Tab count badge ── */
.col-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
background: rgba(1,105,111,0.1);
color: #01696f;
}
/* ── Smart assign ── */
.col-smart-modes {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.col-smart-mode-pill {
display: inline-flex;
align-items: center;
gap: 5px;
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-muted);
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.col-smart-mode-pill:hover {
border-color: rgba(0,0,0,0.15);
color: var(--text-primary);
}
.col-smart-mode-active {
background: #01696f;
color: #fff !important;
border-color: #01696f !important;
}
.col-smart-criteria {
display: flex;
flex-direction: column;
gap: 12px;
}
.col-smart-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.col-smart-action-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0,0,0,0.06);
margin-top: 4px;
}
.col-smart-preview {
flex: 1;
display: flex;
align-items: center;
min-height: 34px;
}
</style>