2725 lines
97 KiB
Vue
2725 lines
97 KiB
Vue
<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 — 0–29 days</option>
|
||
<option value="B">B — 30–59 days</option>
|
||
<option value="C">C — 60–89 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>
|