Case Docket
System
Forgot password?
Lippincott Docket
?
Active matters
Hold legal
Deadlines this week
Bankruptcy
Total matters

Recent activity

Last 10 actions
Loading…

Upcoming deadlines

Next 30 days
Loading…

All matters

File # Debtor Client Rep Status Last action date Last action
Loading…
Case Detail
Loading…

Upcoming deadlines

Loading…

Import Word / PDF docket

Click to upload .docx or .pdf docket file

AI will extract all matters and journal entries automatically

Import Legal Action Request (LAR)

Upload a single LAR PDF to extract debtor info, loan details, and a financial snapshot. The matter will be created if it doesn't exist, or updated (blank fields only) if it does. Match is by loan account number.

Click to upload LAR PDF

Single file · creates or updates one matter

Import format guide

Paralegals should format Word documents as follows for automatic import:

MATTER: [Last, First]
FILE: [1208-XXX]
TRIAD FILE: [XXXXXX]
CLIENT: [TRIAD / TRANSPECOS / HH_CONCRETE / HH_TILE]
STATUS: [Active / Hold Legal / Bankruptcy / Closed]
REP: [Shelar / Bigelow / Davis / etc.]

ENTRY [MM/DD/YY] [INITIALS]: [status note]
ENTRY [MM/DD/YY] [INITIALS]: [status note]

Import history

Loading…

User management

Loading…
`; } // Render the per-matter "summary" block (no journal). Used for the summary detail level. function renderSummaryBlock(m, escHtml, fmtMoney) { const fields = []; if (m.our_file_no) fields.push(['File #', m.our_file_no, true]); if (m.client_file_no) fields.push(['Client file #', m.client_file_no, true]); if (m.assignor) fields.push(['Assignor', m.assignor, false]); if (m.rep_name) fields.push(['Rep', m.rep_name, false]); if (m.debtor_address) fields.push(['Debtor address', m.debtor_address, false]); if (m.mh_address && m.mh_address !== m.debtor_address) fields.push(['MH address', m.mh_address, false]); if (m.dob) fields.push(['DOB', m.dob, true]); if (m.ssn_last4) fields.push(['SSN (last 4)', '•••-••-' + m.ssn_last4, true]); if (m.co_debtor_name) fields.push(['Co-debtor', m.co_debtor_name, false]); if (m.loan_account_no) fields.push(['Loan account', m.loan_account_no, true]); if (m.loan_type) fields.push(['Loan type', m.loan_type, false]); if (m.date_of_loan) fields.push(['Date of loan', m.date_of_loan, true]); if (m.original_amf != null) fields.push(['Original AMF', fmtMoney(m.original_amf), false]); if (m.mhp_name) fields.push(['MHP', m.mhp_name, false]); const homeBits = [m.home_year, m.home_make, m.home_model, m.home_size].filter(Boolean).join(' '); if (homeBits) fields.push(['Home', homeBits, false]); if (m.home_serial_no) fields.push(['Serial #', m.home_serial_no, true]); if (m.reason_for_default) fields.push(['Reason for default', m.reason_for_default, false]); if (m.client_notes) fields.push(['Client notes', m.client_notes, false]); const counselBits = []; if (m.counsel_name) counselBits.push(['Attorney', m.counsel_name, false]); if (m.counsel_firm) counselBits.push(['Firm', m.counsel_firm, false]); if (m.counsel_email) counselBits.push(['Email', m.counsel_email, false]); if (m.counsel_phone) counselBits.push(['Phone', m.counsel_phone, false]); if (m.counsel_address) counselBits.push(['Address', m.counsel_address, false]); return `
${escHtml(m.debtor_name)}
${escHtml(m.our_file_no || '')} · ${escHtml(CLIENT_LABELS[m.client_code] || m.client_code)} · ${escHtml(m.status)}
${fields.length ? `
${fields.map(([l,v,mono]) => `
${escHtml(l)}${escHtml(v)}
`).join('')}
` : ''} ${counselBits.length ? `
Debtor's counsel
${counselBits.map(([l,v]) => `
${escHtml(l)}${escHtml(v)}
`).join('')}
` : ''} ${m.last_action_summary ? `
Last action ${m.last_action_date ? '(' + escHtml(m.last_action_date) + ')' : ''}
${escHtml(m.last_action_summary)}
` : ''}
`; } // Render the per-matter "full" block (everything + journal entries). One per page. function renderFullBlock(m, entries, escHtml, fmtMoney) { // Reuse the summary block's fields then append journal table const summaryHtml = renderSummaryBlock(m, escHtml, fmtMoney) .replace('class="matter-block"', 'class="matter-block full"'); // Strip the "Last action" section from summary block since the journal will show it in context const summaryHtmlNoLastAction = summaryHtml.replace( /
Last action[\s\S]*?<\/div>\s*
[\s\S]*?<\/div>/, '' ); let journalHtml = ''; if (entries.length === 0) { journalHtml = `
No entries for this matter${' in the selected date range' ? '' : ''}.
`; } else { journalHtml = ` ${entries.map(e => ``).join('')}
#DateByCategoryNote
${escHtml(e.entry_number || '')} ${escHtml(e.entry_date || '')} ${escHtml(e.entered_by || '')} ${escHtml(e.category && e.category !== 'General' ? e.category : '')}${e.category_date ? ` (${escHtml(e.category_date)})` : ''} ${escHtml(e.status_note || '')}
`; } // Splice the journal HTML in just before the closing
of the matter-block return summaryHtmlNoLastAction.replace(/<\/div>\s*$/, journalHtml + '
'); } function updateCounts() { document.getElementById('cnt-all').textContent = allMatters.length; ['TRIAD','TRANSPECOS','HH_CONCRETE','HH_TILE'].forEach(c => { const el = document.getElementById('cnt-'+c); if (el) el.textContent = allMatters.filter(m => m.client_code === c).length; }); } function getFiltered() { return allMatters.filter(m => { const matchClient = activeClient === 'all' || m.client_code === activeClient; const matchStatus = !activeStatus || m.status === activeStatus; const statusFilter = document.getElementById('f-status') ? document.getElementById('f-status').value : ''; const repFilter = document.getElementById('f-rep') ? document.getElementById('f-rep').value : ''; const typeFilter = document.getElementById('f-type') ? document.getElementById('f-type').value : ''; const matchSF = !statusFilter || m.status === statusFilter; const matchRep = !repFilter || m.rep_name === repFilter; const matchType = !typeFilter || m.loan_type === typeFilter; const q = searchQuery.trim(); let matchSearch = true; if (q) { const predicate = getQueryPredicate(q); const haystack = searchTextByMatter[m.id] || ''; matchSearch = predicate(haystack); } return matchClient && matchStatus && matchSF && matchRep && matchType && matchSearch; }); } function renderDashboard() { const active = allMatters.filter(m => m.status === 'Active').length; const hold = allMatters.filter(m => m.status === 'Hold Legal').length; const bk = allMatters.filter(m => m.status === 'Bankruptcy').length; const inactive = allMatters.filter(m => m.status === 'Inactive' || m.status === 'Closed' || m.status === 'Dismissed').length; document.getElementById('s-active').textContent = active; document.getElementById('s-hold').textContent = hold; document.getElementById('s-bk').textContent = bk; document.getElementById('s-total').textContent = allMatters.length; const today = new Date(); const weekOut = new Date(today.getTime() + 7*86400000); const todayStr = today.toISOString().split('T')[0]; const weekStr = weekOut.toISOString().split('T')[0]; // Load deadlines count db.from('deadlines').select('id').eq('completed', false) .gte('deadline_date', todayStr).lte('deadline_date', weekStr) .then(({ data }) => document.getElementById('s-deadlines').textContent = data ? data.length : 0); // Recent matters const recent = allMatters.slice(0, 10); document.getElementById('recent-list').innerHTML = recent.length ? ` ${recent.map(m => ``).join('')}
DateDebtorClientStatusLast action
${m.last_action_date||'—'} ${m.debtor_name} ${CLIENT_LABELS[m.client_code]||m.client_code} ${badgeHtml(m.status)} ${m.last_action_summary||''}
` : '
No matters yet
'; // Upcoming deadlines db.from('deadlines').select('*, matters(debtor_name, our_file_no)') .eq('completed', false).gte('deadline_date', todayStr) .order('deadline_date').limit(8) .then(({ data }) => { if (!data || !data.length) { document.getElementById('dash-deadlines').innerHTML = '
No upcoming deadlines
'; return; } document.getElementById('dash-deadlines').innerHTML = data.map(d => { const diff = Math.ceil((new Date(d.deadline_date+'T12:00:00') - today) / 86400000); const cls = diff <= 3 ? 'overdue' : diff <= 7 ? 'soon' : ''; const matter = d.matters; return `
${d.deadline_date} ${matter ? matter.debtor_name : '—'} ${d.description} ${d.deadline_type||''}
`; }).join(''); }); } function setSort(col) { if (sortCol === col) { sortAsc = !sortAsc; } else { sortCol = col; sortAsc = col !== 'last_action_date'; } // Update arrow indicators document.querySelectorAll('[id^="sh-"] .sort-arrow').forEach(el => el.textContent = ''); const header = document.getElementById('sh-' + col); if (header) header.querySelector('.sort-arrow').textContent = sortAsc ? ' ▲' : ' ▼'; renderMatters(); } function renderMatters() { const filtered = getFiltered(); // Sort filtered.sort((a, b) => { let va = (a[sortCol] || '').toString().toLowerCase(); let vb = (b[sortCol] || '').toString().toLowerCase(); // Dates sort correctly as strings (YYYY-MM-DD) if (va < vb) return sortAsc ? -1 : 1; if (va > vb) return sortAsc ? 1 : -1; return 0; }); document.getElementById('matters-heading').textContent = `${filtered.length} matter${filtered.length !== 1 ? 's' : ''}`; document.getElementById('matters-tbody').innerHTML = filtered.length ? filtered.map(m => { const isDeleted = !!m.deleted_at; const rowStyle = isDeleted ? 'opacity:0.5;background:rgba(0,0,0,0.02);' : ''; const onClick = isDeleted ? '' : `onclick="openDetail('${m.id}')"`; const cls = isDeleted ? '' : 'clickable'; const summary = isDeleted ? `Deleted ${fmtLocalDateTime(m.deleted_at)} · Restore` : (m.last_action_summary || ''); return ` ${m.our_file_no||'—'} ${m.debtor_name} ${CLIENT_LABELS[m.client_code]||m.client_code} ${m.rep_name||'—'} ${badgeHtml(m.status)} ${m.last_action_date||'—'} ${summary} `; }).join('') : 'No matters found'; } async function openDetail(id) { currentMatterId = id; showSection('detail'); document.getElementById('detail-card').innerHTML = '
Loading…
'; const m = allMatters.find(x => x.id === id); if (!m) return; const { data: entries } = await db.from('journal_entries') .select('*').eq('matter_id', id) .order('entry_date').order('entry_number'); const { data: deadlines } = await db.from('deadlines') .select('*').eq('matter_id', id).eq('completed', false) .order('deadline_date'); const { data: financials } = await db.from('matter_financials') .select('*').eq('matter_id', id) .order('snapshot_date', { ascending: false }).limit(1); const fin = financials && financials.length ? financials[0] : null; const fmtMoney = (n) => n != null ? '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'; // Cache entries in window so the activity-filter pills can re-render without re-fetching window._currentEntries = entries || []; window._currentActivityFilter = window._currentActivityFilter || 'All'; const counselFieldsPresent = !!(m.counsel_name || m.counsel_firm || m.counsel_email || m.counsel_phone || m.counsel_address || m.counsel_website || m.counsel_notes); document.getElementById('detail-card').innerHTML = `
${escapeHtml(m.debtor_name)}
${CLIENT_LABELS[m.client_code]||m.client_code}  ·  ${escapeHtml(m.our_file_no||'')}  ·  Rep: ${escapeHtml(m.rep_name||'—')}${m.box_folder_id ? `  ·  Box folder ↗` : ''}
${badgeHtml(m.status)}
Our file #${escapeHtml(m.our_file_no||'—')}
Client file #${escapeHtml(m.client_file_no||'—')}
Assignor${escapeHtml(m.assignor||'—')}
Debtor address${escapeHtml(m.debtor_address||'—')}
MH address${escapeHtml(m.mh_address||'—')}
Status${escapeHtml(m.status)}
Last action date${m.last_action_date||'—'}
Last action${escapeHtml(m.last_action_summary||'—')}
Client notes${escapeHtml(m.client_notes||'—')}
${deadlines && deadlines.length ? `
Open deadlines
${deadlines.map(d => `
${d.deadline_date} — ${escapeHtml(d.description)}
`).join('')}
` : ''}
Loan & borrower
${m.loan_account_no ? `
Loan account #${escapeHtml(m.loan_account_no)}
` : ''} ${m.loan_type ? `
Loan type${escapeHtml(m.loan_type)}
` : ''} ${m.date_of_loan ? `
Date of loan${m.date_of_loan}
` : ''} ${m.original_amf != null ? `
Original AMF${fmtMoney(m.original_amf)}
` : ''} ${m.dob ? `
DOB${m.dob}
` : ''} ${m.ssn_last4 ? `
SSN (last 4)•••-••-${m.ssn_last4}
` : ''} ${m.co_debtor_name ? `
Co-debtor${escapeHtml(m.co_debtor_name)}
` : ''} ${m.mhp_name ? `
MHP${escapeHtml(m.mhp_name)}
` : ''} ${m.home_year || m.home_make ? `
Home${escapeHtml([m.home_year, m.home_make, m.home_model, m.home_size].filter(Boolean).join(' '))}
` : ''} ${m.home_serial_no ? `
Serial #${escapeHtml(m.home_serial_no)}
` : ''} ${!(m.loan_account_no || m.dob || m.mhp_name || m.home_year) ? '
No loan or borrower data yet. Click Import LAR above, or Edit to fill in manually.
' : ''}
${fin ? `
Latest financial snapshot (${fin.snapshot_date})
${fin.principal_balance != null ? `
Principal balance${fmtMoney(fin.principal_balance)}
` : ''} ${fin.payoff_amount != null ? `
Payoff${fmtMoney(fin.payoff_amount)}${fin.per_diem != null ? ` + ${fmtMoney(fin.per_diem)}/day` : ''}
` : ''} ${fin.amount_past_due != null ? `
Past due${fmtMoney(fin.amount_past_due)}
` : ''} ${fin.total_due != null ? `
Total due${fmtMoney(fin.total_due)}
` : ''} ${fin.nada_value != null ? `
NADA value${fmtMoney(fin.nada_value)}
` : ''} ${fin.nod_expired_date ? `
NOD expired${fin.nod_expired_date}
` : ''} ${fin.due_date ? `
Next due${fin.due_date}
` : ''} ${fin.last_paid_date ? `
Last paid${fin.last_paid_date}
` : ''} ` : '
No financial data yet — click Import LAR above to load borrower & loan details.
'}

Case Activity Entries

Debtor's counsel

${counselFieldsPresent ? `
${m.counsel_name ? `
Attorney${escapeHtml(m.counsel_name)}
` : ''} ${m.counsel_firm ? `
Firm${escapeHtml(m.counsel_firm)}
` : ''} ${m.counsel_email ? `` : ''} ${m.counsel_phone ? `
Phone${escapeHtml(m.counsel_phone)}
` : ''} ${m.counsel_website ? `` : ''}
${m.counsel_address ? `
Address${escapeHtml(m.counsel_address)}
` : ''} ${m.counsel_notes ? `
Notes${escapeHtml(m.counsel_notes)}
` : ''}
` : '
No debtor\'s counsel on file. Click + Add to enter contact information.
'}
`; // Render activity panel contents renderActivityFilterBar(); renderActivityList(); } // Switch active tab on the case detail page function switchCaseTab(tab) { document.querySelectorAll('.case-tab').forEach(t => { t.classList.toggle('active', t.dataset.tab === tab); }); document.querySelectorAll('.case-tab-panel').forEach(p => { p.classList.toggle('active', p.id === 'case-tab-' + tab); }); } // Render the activity-filter pill bar based on the categories present in window._currentEntries function renderActivityFilterBar() { const entries = window._currentEntries || []; const counts = { All: entries.length }; for (const e of entries) { const c = e.category || 'General'; counts[c] = (counts[c] || 0) + 1; } const order = ['All', 'Case Update', 'Status Change', 'Case Deadline', 'Foreclosure Date', 'General']; const present = order.filter(c => c === 'All' || counts[c]); const active = window._currentActivityFilter || 'All'; const html = present.map(c => { const cls = active === c ? 'activity-filter-pill active' : 'activity-filter-pill'; return ``; }).join(''); const bar = document.getElementById('activity-filter-bar'); if (bar) bar.innerHTML = html; } function setActivityFilter(filter) { window._currentActivityFilter = filter; renderActivityFilterBar(); renderActivityList(); } // Render the list of activity entries, filtered by the current activity filter function renderActivityList() { const entries = window._currentEntries || []; const filter = window._currentActivityFilter || 'All'; const visible = filter === 'All' ? entries : entries.filter(e => (e.category || 'General') === filter); const list = document.getElementById('activity-list'); if (!list) return; if (!visible.length) { list.innerHTML = `
No ${filter === 'All' ? '' : filter + ' '}entries
`; return; } list.innerHTML = visible.map(e => { const cat = e.category || 'General'; const catCss = 'cat-' + cat.replace(/\s+/g, ''); const catDate = e.category_date ? `[${cat === 'Case Deadline' ? 'Due' : cat === 'Foreclosure Date' ? 'Foreclosure' : 'Date'}: ${e.category_date}]` : ''; return `
#${e.entry_number || ''} ${e.entry_date} ${escapeHtml(e.entered_by || '')} ${escapeHtml(cat)} ${catDate}
${escapeHtml(e.status_note || '')}
`; }).join(''); } async function renderDeadlines() { const today = new Date().toISOString().split('T')[0]; const { data } = await db.from('deadlines') .select('*, matters(debtor_name, our_file_no)') .order('deadline_date').limit(100); if (!data || !data.length) { document.getElementById('deadlines-list').innerHTML = '
No deadlines
'; return; } const todayDt = new Date(); document.getElementById('deadlines-list').innerHTML = data.map(d => { const diff = Math.ceil((new Date(d.deadline_date+'T12:00:00') - todayDt) / 86400000); const cls = !d.completed && diff <= 3 ? 'overdue' : diff <= 7 ? 'soon' : ''; const m = d.matters; return `
${d.deadline_date}${d.completed?' ✓':''} ${m ? m.debtor_name : '—'} ${m ? m.our_file_no : ''} ${d.description} ${d.deadline_type||''} ${!d.completed ? `` : ''}
`; }).join(''); } async function markDeadlineDone(id) { await db.from('deadlines').update({ completed: true, completed_date: new Date().toISOString().split('T')[0] }).eq('id', id); renderDeadlines(); toast('Deadline marked complete'); } async function renderImportHistory() { const { data } = await db.from('import_logs').select('*').order('created_at', { ascending: false }).limit(20); if (!data || !data.length) { document.getElementById('import-history').innerHTML = '
No imports yet
'; return; } document.getElementById('import-history').innerHTML = `${data.map(r => ``).join('')}
DateFileByFoundNewUpdatedEntriesSkippedStatus
${r.created_at ? fmtLocalDateTime(r.created_at) : '—'} ${r.source_filename||'—'} ${r.imported_by||'—'} ${r.matters_found||0} ${r.matters_created||0} ${r.matters_updated||0} ${r.entries_created||0} ${r.entries_skipped_duplicate||0} ${r.errors ? '⚠ errors' : '✓ ok'} ${r.batch_id && !r.rolled_back_at ? `` : r.rolled_back_at ? 'Rolled back' : '—'}
`; } function showSection(name) { document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); document.getElementById('section-'+name).classList.add('active'); document.querySelectorAll('.nav-btn').forEach(b => { b.classList.toggle('active', b.getAttribute('onclick') && b.getAttribute('onclick').includes("'"+name+"'")); }); if (name === 'matters') renderMatters(); if (name === 'deadlines') renderDeadlines(); if (name === 'import') renderImportHistory(); if (name === 'admin') renderAdminUsers(); if (name === 'dashboard') renderDashboard(); } function filterClient(el, code) { document.querySelectorAll('[data-client]').forEach(x => x.classList.remove('active')); el.classList.add('active'); activeClient = code; activeStatus = ''; document.querySelectorAll('[data-status]').forEach(x => x.classList.remove('active')); if (document.getElementById('section-matters').classList.contains('active')) renderMatters(); else showSection('matters'); } function filterStatus(el, status) { document.querySelectorAll('[data-status]').forEach(x => x.classList.remove('active')); el.classList.add('active'); activeStatus = status; activeClient = 'all'; document.querySelectorAll('[data-client]').forEach(x => x.classList.remove('active')); document.querySelector('[data-client="all"]').classList.add('active'); showSection('matters'); } function handleSearch(val) { searchQuery = val; // Parse the query proactively so the error pill updates immediately, even when the // matters section isn't active (so you can see syntax errors as you type from anywhere). getQueryPredicate(val); updateSearchErrorPill(); if (document.getElementById('section-matters').classList.contains('active')) renderMatters(); } function openNewMatter() { document.getElementById('modal-matter').classList.add('open'); } function openNewEntry() { // Reset to "Add" mode window._editingEntryId = null; document.getElementById('entry-modal-title').textContent = 'Add case activity entry'; document.getElementById('entry-save-btn').textContent = 'Save entry'; document.getElementById('e-date').value = new Date().toISOString().split('T')[0]; document.getElementById('e-by').value = currentUser?.user_metadata?.initials || ''; document.getElementById('e-note').value = ''; document.getElementById('e-category').value = 'General'; document.getElementById('e-category-date').value = ''; document.getElementById('e-dl-date').value = ''; document.getElementById('e-dl-desc').value = ''; onEntryCategoryChange(); document.getElementById('modal-entry').classList.add('open'); } // Show/hide the category-specific date field based on category selection function onEntryCategoryChange() { const cat = document.getElementById('e-category').value; const wrap = document.getElementById('e-category-date-wrap'); const label = document.getElementById('e-category-date-label'); const dlWrap = document.getElementById('e-deadline-wrap'); if (cat === 'Case Deadline') { wrap.style.display = ''; label.textContent = 'Deadline date'; dlWrap.style.display = 'none'; // category date IS the deadline; don't double up } else if (cat === 'Foreclosure Date') { wrap.style.display = ''; label.textContent = 'Foreclosure date'; dlWrap.style.display = ''; } else { wrap.style.display = 'none'; document.getElementById('e-category-date').value = ''; dlWrap.style.display = ''; } } // Open the entry modal in edit mode for an existing entry async function openEntryEdit(entryId) { const { data: e, error } = await db.from('journal_entries').select('*').eq('id', entryId).single(); if (error || !e) { toast('Could not load entry', 'error'); return; } window._editingEntryId = entryId; document.getElementById('entry-modal-title').textContent = 'Edit case activity entry'; document.getElementById('entry-save-btn').textContent = 'Save changes'; document.getElementById('e-date').value = e.entry_date || ''; document.getElementById('e-by').value = e.entered_by || ''; document.getElementById('e-note').value = e.status_note || ''; document.getElementById('e-category').value = e.category || 'General'; document.getElementById('e-category-date').value = e.category_date || ''; document.getElementById('e-dl-date').value = ''; document.getElementById('e-dl-desc').value = ''; onEntryCategoryChange(); document.getElementById('modal-entry').classList.add('open'); } async function confirmDeleteEntry(entryId) { if (!confirm('Delete this activity entry? This cannot be undone.')) return; const { error } = await db.from('journal_entries').delete().eq('id', entryId); if (error) { toast('Delete failed: ' + error.message, 'error'); return; } toast('Entry deleted'); await openDetail(currentMatterId); } function openNewDeadline() { document.getElementById('modal-deadline').classList.add('open'); } function closeModal(id) { document.getElementById(id).classList.remove('open'); } async function saveMatter() { const debtor = document.getElementById('m-debtor').value.trim(); const ourFile = document.getElementById('m-ourfile').value.trim(); const client = document.getElementById('m-client').value; if (!debtor) { toast('Debtor name is required', 'error'); return; } // Get client UUID const { data: clients } = await db.from('clients').select('id').eq('short_code', client).single(); if (!clients) { toast('Client not found', 'error'); return; } const today = new Date().toISOString().split('T')[0]; const { data, error } = await db.from('matters').insert({ client_id: clients.id, our_file_no: ourFile, client_file_no: document.getElementById('m-clientfile').value.trim(), debtor_name: debtor, assignor: document.getElementById('m-assignor').value.trim(), rep_name: document.getElementById('m-rep').value, loan_type: document.getElementById('m-proptype').value || null, status: document.getElementById('m-status').value, debtor_address: document.getElementById('m-addr').value.trim(), mh_address: document.getElementById('m-mhaddr').value.trim(), client_notes: document.getElementById('m-notes').value.trim(), opened_date: today }).select(); if (error) { toast('Error: ' + error.message, 'error'); return; } // Auto-log a "File opened" activity entry on the new matter so the timeline starts there. // The trigger on journal_entries will set last_action_date/summary on the parent matter. try { const newMatter = data && data[0]; if (newMatter) { const by = (currentUser?.email || 'system').split('@')[0].toUpperCase().substring(0, 5); const note = 'File opened.'; const hash = await sha256(`${newMatter.id}${today}${by}${note}file-opened`); await db.from('journal_entries').insert({ matter_id: newMatter.id, entry_number: 1, entry_date: today, entered_by: by, status_note: note, category: 'General', entry_hash: hash, ai_source: 'auto-file-opened', }); } } catch (e) { console.warn('Failed to log file-opened entry:', e); } closeModal('modal-matter'); await loadMatters(); toast('Matter created: ' + debtor); renderMatters(); } async function rollbackImport(batchId, mattersCreated, entriesCreated) { if (!confirm(`Roll back this import? This will permanently delete ${mattersCreated} matters and ${entriesCreated} journal entries that were created by this import. This cannot be undone.`)) return; const { data, error } = await db.rpc('rollback_import', { p_batch_id: batchId }); if (error) { toast('Rollback failed: ' + error.message, 'error'); return; } // Mark import log as rolled back await db.from('import_logs').update({ rolled_back_at: new Date().toISOString(), rolled_back_by: currentUser?.email || 'admin' }).eq('batch_id', batchId); const result = data?.[0]; toast(`Rolled back: ${result?.matters_deleted || 0} matters, ${result?.entries_deleted || 0} entries deleted`); await loadMatters(); renderImportHistory(); } async function changeStatus(newStatus) { if (!newStatus || !currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); const oldStatus = m?.status; if (oldStatus === newStatus) { toast('Status unchanged'); return; } const today = new Date().toISOString().split('T')[0]; const { error } = await db.from('matters').update({ status: newStatus, updated_at: new Date().toISOString(), }).eq('id', currentMatterId); if (error) { toast('Error updating status: ' + error.message, 'error'); return; } // Auto-log a Status Change activity entry try { const { count } = await db.from('journal_entries').select('*', { count:'exact', head:true }).eq('matter_id', currentMatterId); const by = (currentUser?.email || 'system').split('@')[0].toUpperCase().substring(0, 5); const note = `Status changed from ${oldStatus || '—'} to ${newStatus}.`; const hash = await sha256(`${currentMatterId}${today}${by}${note}status-change`); await db.from('journal_entries').insert({ matter_id: currentMatterId, entry_number: (count || 0) + 1, entry_date: today, entered_by: by, status_note: note, category: 'Status Change', entry_hash: hash, ai_source: 'auto-status-change', }); } catch (e) { // Non-fatal: status was changed; logging failure isn't worth aborting on console.warn('Failed to log status change entry:', e); } await loadMatters(); await openDetail(currentMatterId); toast('Status updated to ' + newStatus); } // Soft-delete the current matter. Sets deleted_at; matter disappears from default views but // can be restored. Hard delete (irreversible) is a separate admin-only action. async function confirmDeleteMatter() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; // Show counts so the user knows what's attached before deciding const [{ count: entryCount }, { count: finCount }, { count: dlCount }] = await Promise.all([ db.from('journal_entries').select('*', { count: 'exact', head: true }).eq('matter_id', currentMatterId), db.from('matter_financials').select('*', { count: 'exact', head: true }).eq('matter_id', currentMatterId), db.from('deadlines').select('*', { count: 'exact', head: true }).eq('matter_id', currentMatterId), ]); const counts = `${entryCount || 0} journal entries, ${finCount || 0} financial snapshots, ${dlCount || 0} deadlines`; const ok = confirm( `Delete matter "${m.debtor_name}" (${m.our_file_no || 'no file #'})?\n\n` + `This matter has: ${counts}\n\n` + `The matter will be hidden but can be restored. Click OK to soft-delete.` ); if (!ok) return; const { error } = await db.from('matters').update({ deleted_at: new Date().toISOString(), deleted_by: currentUser?.email || null, }).eq('id', currentMatterId); if (error) { toast('Error deleting: ' + error.message, 'error'); return; } toast(`Matter deleted (can be restored from "Show deleted" view)`); currentMatterId = null; await loadMatters(); showSection('matters'); renderMatters(); } async function restoreMatter(id) { const { error } = await db.from('matters').update({ deleted_at: null, deleted_by: null, }).eq('id', id); if (error) { toast('Error restoring: ' + error.message, 'error'); return; } toast('Matter restored'); await loadMatters(); renderMatters(); } async function toggleShowDeleted() { window._showDeleted = !window._showDeleted; const btn = document.getElementById('show-deleted-toggle'); if (btn) btn.textContent = window._showDeleted ? '✓ Show deleted' : 'Show deleted'; await loadMatters(); renderMatters(); } // ========== Debtor's counsel ========== function openCounselEdit() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; document.getElementById('c-name').value = m.counsel_name || ''; document.getElementById('c-firm').value = m.counsel_firm || ''; document.getElementById('c-email').value = m.counsel_email || ''; document.getElementById('c-phone').value = m.counsel_phone || ''; document.getElementById('c-address').value = m.counsel_address || ''; document.getElementById('c-website').value = m.counsel_website || ''; document.getElementById('c-notes').value = m.counsel_notes || ''; document.getElementById('modal-counsel').classList.add('open'); } async function saveCounsel() { if (!currentMatterId) return; const norm = v => { const t = (v || '').trim(); return t === '' ? null : t; }; const update = { counsel_name: norm(document.getElementById('c-name').value), counsel_firm: norm(document.getElementById('c-firm').value), counsel_email: norm(document.getElementById('c-email').value), counsel_phone: norm(document.getElementById('c-phone').value), counsel_address: norm(document.getElementById('c-address').value), counsel_website: norm(document.getElementById('c-website').value), counsel_notes: norm(document.getElementById('c-notes').value), }; const { error } = await db.from('matters').update(update).eq('id', currentMatterId); if (error) { toast('Save failed: ' + error.message, 'error'); return; } closeModal('modal-counsel'); await loadMatters(); await openDetail(currentMatterId); switchCaseTab('counsel'); toast('Counsel saved'); } async function clearCounsel() { if (!confirm("Clear all debtor's counsel info on this matter?")) return; document.getElementById('c-name').value = ''; document.getElementById('c-firm').value = ''; document.getElementById('c-email').value = ''; document.getElementById('c-phone').value = ''; document.getElementById('c-address').value = ''; document.getElementById('c-website').value = ''; document.getElementById('c-notes').value = ''; // Save the cleared state await saveCounsel(); } // ========== Comprehensive case-details edit (one modal, all tabs) ========== // Field map: DOM id suffix → matter column. The DOM ids all start with "ec-" so we // build the id by prefix. Keep this list in sync with the modal HTML above. const EDIT_CASE_FIELDS = [ // Overview / admin 'debtor_name', 'co_debtor_name', 'our_file_no', 'client_file_no', 'assignor', 'rep_name', 'status', 'hold_reason', 'debtor_address', 'mh_address', 'client_notes', // Loan / borrower 'dob', 'ssn_last4', 'co_dob', 'co_ssn_last4', 'loan_account_no', 'loan_type', 'date_of_loan', 'original_amf', 'mhp_name', 'reason_for_default', 'requested_by', 'requested_date', // Home 'home_year', 'home_make', 'home_model', 'home_size', 'home_serial_no', // Counsel 'counsel_name', 'counsel_firm', 'counsel_email', 'counsel_phone', 'counsel_address', 'counsel_website', 'counsel_notes', ]; // Numeric-typed fields (so we serialize them as numbers, not strings) const EDIT_CASE_NUMERIC_FIELDS = new Set(['original_amf', 'home_year']); function openEditCaseDetails() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; for (const f of EDIT_CASE_FIELDS) { const el = document.getElementById('ec-' + f); if (!el) continue; const v = m[f]; el.value = (v == null) ? '' : String(v); } document.getElementById('modal-edit-case').classList.add('open'); } async function saveCaseDetails() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; const norm = v => { const t = (v || '').trim(); return t === '' ? null : t; }; const update = {}; for (const f of EDIT_CASE_FIELDS) { const el = document.getElementById('ec-' + f); if (!el) continue; let v = norm(el.value); if (v != null && EDIT_CASE_NUMERIC_FIELDS.has(f)) { const n = Number(v); v = isNaN(n) ? null : n; } update[f] = v; } // debtor_name and status are NOT NULL — guard if (!update.debtor_name) { toast('Debtor name is required', 'error'); return; } if (!update.status) { toast('Status is required', 'error'); return; } // SSN format check (DB has a check constraint that would reject anyway, but better UX) for (const k of ['ssn_last4', 'co_ssn_last4']) { if (update[k] != null && !/^\d{4}$/.test(update[k])) { toast(`${k === 'ssn_last4' ? 'SSN' : 'Co-debtor SSN'} (last 4) must be exactly 4 digits`, 'error'); return; } } // Detect a status change so we can log a Status Change journal entry (mirrors changeStatus()) const statusChanged = (m.status || null) !== (update.status || null); // updated_at is auto-managed by Postgres? It's nullable with default now() — set explicitly update.updated_at = new Date().toISOString(); const { error } = await db.from('matters').update(update).eq('id', currentMatterId); if (error) { toast('Save failed: ' + error.message, 'error'); return; } // Log status change as a journal entry, same as the dropdown does if (statusChanged) { try { const today = new Date().toISOString().split('T')[0]; const { count } = await db.from('journal_entries').select('*', { count:'exact', head:true }).eq('matter_id', currentMatterId); const by = (currentUser?.email || 'system').split('@')[0].toUpperCase().substring(0, 5); const note = `Status changed from ${m.status || '—'} to ${update.status}.`; const hash = await sha256(`${currentMatterId}${today}${by}${note}status-change`); await db.from('journal_entries').insert({ matter_id: currentMatterId, entry_number: (count || 0) + 1, entry_date: today, entered_by: by, status_note: note, category: 'Status Change', entry_hash: hash, ai_source: 'auto-status-change', }); } catch (e) { console.warn('Failed to log status change entry:', e); } } closeModal('modal-edit-case'); await loadMatters(); await openDetail(currentMatterId); toast('Case details saved'); } async function saveEntry() { const note = document.getElementById('e-note').value.trim(); const date = document.getElementById('e-date').value; const by = document.getElementById('e-by').value.trim() || 'EAC'; const category = document.getElementById('e-category').value || 'General'; const categoryDate = document.getElementById('e-category-date').value || null; if (!note || !date) { toast('Date and note are required', 'error'); return; } if ((category === 'Case Deadline' || category === 'Foreclosure Date') && !categoryDate) { toast(`A date is required for ${category} entries`, 'error'); return; } const editingId = window._editingEntryId; if (editingId) { // EDIT MODE — update the existing row directly const { error } = await db.from('journal_entries').update({ entry_date: date, entered_by: by, status_note: note, category, category_date: categoryDate, }).eq('id', editingId); if (error) { toast('Update failed: ' + error.message, 'error'); return; } closeModal('modal-entry'); window._editingEntryId = null; await loadMatters(); await openDetail(currentMatterId); toast('Entry updated'); return; } // ADD MODE — insert a new row. // Compute the next entry number client-side (matches existing convention) const { count } = await db.from('journal_entries').select('*', { count:'exact', head:true }).eq('matter_id', currentMatterId); // Compute a hash matching the original RPC convention (matter_id + date + entered_by + note) // so any future re-import doesn't double up. // (Hash logic here mirrors insert_entry_if_new for compatibility.) const hashSource = `${currentMatterId}${date}${by}${note}`; const entryHash = await sha256(hashSource); // Quick dedupe check const { data: existing } = await db.from('journal_entries').select('id').eq('entry_hash', entryHash).maybeSingle(); if (existing) { toast('This entry already exists — skipped', 'error'); return; } const { error: insErr } = await db.from('journal_entries').insert({ matter_id: currentMatterId, entry_number: (count || 0) + 1, entry_date: date, entered_by: by, status_note: note, category, category_date: categoryDate, entry_hash: entryHash, ai_source: 'manual', }); if (insErr) { toast('Save failed: ' + insErr.message, 'error'); return; } // last_action_date / last_action_summary on the matter are maintained automatically // by a database trigger that watches journal_entries. // If a separate deadline tracker was filled in, save it too const dlDate = document.getElementById('e-dl-date').value; const dlDesc = document.getElementById('e-dl-desc').value.trim(); if (dlDate && dlDesc) { await db.from('deadlines').insert({ matter_id: currentMatterId, deadline_date: dlDate, description: dlDesc, deadline_type: 'Other' }); } closeModal('modal-entry'); await loadMatters(); await openDetail(currentMatterId); toast('Entry saved'); } // MD5-equivalent hashing isn't built in. We use SHA-256 instead — different hash, but the // frontend-created entries don't need to match server hashes from imports (they have different // ai_source values, so collisions are extremely unlikely in practice). The original RPC's // MD5(matter+date+by+note) lives on for imported rows. async function sha256(str) { const enc = new TextEncoder().encode(str); const buf = await crypto.subtle.digest('SHA-256', enc); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } async function saveDeadline() { const file = document.getElementById('dl-file').value.trim(); const date = document.getElementById('dl-date').value; const desc = document.getElementById('dl-desc').value.trim(); if (!date || !desc) { toast('Date and description required', 'error'); return; } let matterId = currentMatterId; if (file) { const { data } = await db.from('matters').select('id').eq('our_file_no', file).single(); if (data) matterId = data.id; } if (!matterId) { toast('Matter not found', 'error'); return; } await db.from('deadlines').insert({ matter_id: matterId, deadline_date: date, description: desc, deadline_type: document.getElementById('dl-type').value }); closeModal('modal-deadline'); renderDeadlines(); toast('Deadline saved'); } async function handleWordUpload(input) { if (!input.files[0]) return; const file = input.files[0]; document.getElementById('import-zone').style.display = 'none'; document.getElementById('import-progress').style.display = 'block'; document.getElementById('import-result').style.display = 'none'; try { const clientCode = document.getElementById('import-client').value; const { data: { session } } = await db.auth.getSession(); const token = session?.access_token ?? ''; let textContent = ''; // Send raw file as base64 — edge function does all text extraction document.querySelector('#import-progress div:first-child').textContent = 'Reading file…'; const isPdf = file.name.toLowerCase().endsWith('.pdf'); const isDocx = file.name.toLowerCase().endsWith('.docx') || file.name.toLowerCase().endsWith('.doc'); let requestBody; if (isPdf) { // Send raw PDF as base64 — edge function extracts text server-side with pdfjs const arrayBuffer = await file.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); let binary = ''; const chunkSize = 8192; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); } requestBody = { base64: btoa(binary), client: clientCode, filename: file.name }; } else if (isDocx) { // The file might be a real Word document (zip-formatted) OR plain markdown text // saved with a .docx extension. Detect by checking the magic bytes. const arrayBuffer = await file.arrayBuffer(); const firstBytes = new Uint8Array(arrayBuffer.slice(0, 2)); const isZip = firstBytes[0] === 0x50 && firstBytes[1] === 0x4b; // 'PK' if (!isZip) { // Plain text (markdown) saved with .docx extension — read directly document.querySelector('#import-progress div:first-child').textContent = 'Reading document text…'; const text = await file.text(); if (!text.trim()) throw new Error('Document is empty'); requestBody = { text, client: clientCode, filename: file.name }; } else { // Real .docx — convert to HTML so table structure is preserved, then turn // the HTML tables into markdown tables (which is what the TransPecos parser expects). document.querySelector('#import-progress div:first-child').textContent = 'Extracting Word document…'; if (!window.mammoth) { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); } const result = await mammoth.convertToHtml({ arrayBuffer }); if (!result.value || !result.value.trim()) throw new Error('Could not extract content from Word document'); const markdown = htmlToMarkdown(result.value); if (!markdown.trim()) throw new Error('Document had no extractable content'); requestBody = { text: markdown, client: clientCode, filename: file.name }; } } else { const text = await file.text(); requestBody = { text, client: clientCode, filename: file.name }; } document.querySelector('#import-progress div:first-child').textContent = 'Parsing docket data…'; const res = await fetch('https://qzwxtrijygcsjhrnxqtn.supabase.co/functions/v1/parse-docket', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); const result = await res.json(); document.getElementById('import-progress').style.display = 'none'; document.getElementById('import-zone').style.display = 'block'; document.getElementById('import-result').style.display = 'block'; if (!res.ok || result.error) { document.getElementById('import-result').innerHTML = `
Import failed
${result.error || 'Unknown error'}
`; return; } const errHtml = result.errors?.length ? `
Warnings: ${result.errors.join(' | ')}
` : ''; document.getElementById('import-result').innerHTML = `
✓ Import complete — ${file.name}
${result.matters_found}
matters found
${result.matters_created}
new matters
${result.matters_updated}
updated
${result.entries_added}
entries added
${result.entries_skipped > 0 ? `
${result.entries_skipped} duplicate entries skipped
` : ''} ${errHtml}
`; // Refresh matters await loadMatters(); renderImportHistory(); toast('Import complete: ' + result.matters_created + ' new, ' + result.matters_updated + ' updated'); } catch (e) { document.getElementById('import-progress').style.display = 'none'; document.getElementById('import-zone').style.display = 'block'; document.getElementById('import-result').style.display = 'block'; document.getElementById('import-result').innerHTML = `
Error: ${e.message}
`; } // Reset file input so same file can be re-uploaded input.value = ''; } function openNewUser() { document.getElementById('modal-user').classList.add('open'); } // ===================================================== // LAR import (per-matter, in detail view) // ===================================================== function openLarImport() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; // Reset modal state document.getElementById('lar-step-pick').style.display = 'block'; document.getElementById('lar-step-progress').style.display = 'none'; document.getElementById('lar-step-result').style.display = 'none'; document.getElementById('lar-file').value = ''; // Show Box folder link if we have one const boxLinkEl = document.getElementById('lar-box-link'); if (m.box_folder_id) { document.getElementById('lar-box-href').href = `https://app.box.com/folder/${m.box_folder_id}`; boxLinkEl.style.display = 'block'; } else { boxLinkEl.style.display = 'none'; } document.getElementById('modal-lar').classList.add('open'); } async function handleLarUpload(input) { if (!input.files[0]) return; const file = input.files[0]; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; const reviewFirst = document.getElementById('lar-modal-review')?.checked ?? true; document.getElementById('lar-step-pick').style.display = 'none'; document.getElementById('lar-step-progress').style.display = 'block'; document.getElementById('lar-step-result').style.display = 'none'; try { document.getElementById('lar-progress-msg').textContent = 'Extracting LAR text…'; const larText = await extractPdfText(file); if (reviewFirst) { // Preview parse, then open editable form document.getElementById('lar-progress-msg').textContent = 'Parsing LAR data for review…'; const result = await callParseLar({ lar_text: larText, client_code: m.client_code, our_file_no: m.our_file_no, preview: true, }); // Close per-matter LAR modal first so the review modal isn't behind it visually closeModal('modal-lar'); // Reset internal state for next time document.getElementById('lar-step-pick').style.display = 'block'; document.getElementById('lar-step-progress').style.display = 'none'; document.getElementById('lar-step-result').style.display = 'none'; if (result.error) { toast('Parse failed: ' + result.error, 'error'); input.value = ''; return; } openLarReview({ mode: 'import', context: 'matter-detail', lar_text: larText, client_code: m.client_code, our_file_no: m.our_file_no, box_folder_id: m.box_folder_id, match_matter_id: currentMatterId, // explicitly target this matter (skip dup detection) filename: file.name, matter: result.parsed?.matter || {}, financials: result.parsed?.financials || {}, }); input.value = ''; return; } // No-review path: parse + save directly into this matter document.getElementById('lar-progress-msg').textContent = 'Parsing LAR data…'; const result = await callParseLar({ lar_text: larText, matter_id: currentMatterId, client_code: m.client_code, our_file_no: m.our_file_no, box_folder_id: m.box_folder_id, }); document.getElementById('lar-step-progress').style.display = 'none'; document.getElementById('lar-step-result').style.display = 'block'; if (result.error) { document.getElementById('lar-step-result').innerHTML = `
Import failed
${escapeHtml(result.error)}
`; return; } document.getElementById('lar-step-result').innerHTML = larSummaryHtml(result); await loadMatters(); await openDetail(currentMatterId); toast('LAR imported successfully'); } catch (e) { document.getElementById('lar-step-progress').style.display = 'none'; document.getElementById('lar-step-result').style.display = 'block'; document.getElementById('lar-step-result').innerHTML = `
Error: ${escapeHtml(e.message)}
`; } input.value = ''; } // Single-LAR upload from the Import page — no preselected matter. async function handleLarPageUpload(input) { if (!input.files[0]) return; const file = input.files[0]; const clientCode = document.getElementById('lar-import-client').value; const ourFileNo = document.getElementById('lar-import-fileno').value.trim() || null; const reviewFirst = document.getElementById('lar-import-review').checked; document.getElementById('lar-import-zone').style.display = 'none'; document.getElementById('lar-import-progress').style.display = 'block'; document.getElementById('lar-import-result').style.display = 'none'; try { document.getElementById('lar-import-msg').textContent = 'Extracting LAR text…'; const larText = await extractPdfText(file); // Stash the LAR text on window so retry handlers can re-call without re-extracting window._pendingLarUpload = { lar_text: larText, client_code: clientCode, our_file_no: ourFileNo, filename: file.name, }; if (reviewFirst) { // Preview mode: parse without saving, show editable form document.getElementById('lar-import-msg').textContent = 'Parsing LAR data for review…'; const result = await callParseLar({ lar_text: larText, client_code: clientCode, our_file_no: ourFileNo, preview: true, }); document.getElementById('lar-import-progress').style.display = 'none'; document.getElementById('lar-import-zone').style.display = 'block'; if (result.error) { document.getElementById('lar-import-result').style.display = 'block'; document.getElementById('lar-import-result').innerHTML = `
Parse failed — ${escapeHtml(file.name)}
${escapeHtml(result.error)}
`; return; } // Open the review modal pre-filled with parser output openLarReview({ mode: 'import', context: 'import-page', lar_text: larText, client_code: clientCode, our_file_no: ourFileNo, filename: file.name, matter: result.parsed?.matter || {}, financials: result.parsed?.financials || {}, }); input.value = ''; return; } // No-review path: parse + save in one shot (original behavior) document.getElementById('lar-import-msg').textContent = 'Parsing LAR data…'; const result = await callParseLar({ lar_text: larText, client_code: clientCode, our_file_no: ourFileNo, }); document.getElementById('lar-import-progress').style.display = 'none'; document.getElementById('lar-import-zone').style.display = 'block'; document.getElementById('lar-import-result').style.display = 'block'; if (result.error) { document.getElementById('lar-import-result').innerHTML = `
Import failed — ${escapeHtml(file.name)}
${escapeHtml(result.error)}
`; return; } if (result.status === 'needs_review') { document.getElementById('lar-import-result').innerHTML = renderLarCandidatesHtml(result, file.name); return; } // Show summary + button to jump to the matter const matterId = result.matter_id; document.getElementById('lar-import-result').innerHTML = ` ${larSummaryHtml(result, file.name)}
`; delete window._pendingLarUpload; await loadMatters(); renderImportHistory(); toast(`LAR imported (matter ${result.action})`); } catch (e) { document.getElementById('lar-import-progress').style.display = 'none'; document.getElementById('lar-import-zone').style.display = 'block'; document.getElementById('lar-import-result').style.display = 'block'; document.getElementById('lar-import-result').innerHTML = `
Error: ${escapeHtml(e.message)}
`; } input.value = ''; } // Load pdfjs once and extract text from a PDF file (browser-side). async function extractPdfText(file) { if (!window.pdfjsLib) { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); if (window.pdfjsLib) { window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; } } if (!window.pdfjsLib) throw new Error('PDF text extraction library failed to load'); const arrayBuffer = await file.arrayBuffer(); const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise; let text = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent(); text += content.items.map(it => it.str).join(' ') + '\n'; } if (!text.trim()) throw new Error('Could not extract text from PDF — it may be scanned (image-only)'); return text; } // Call the parse-lar edge function. Returns { ...payload } or { error }. async function callParseLar(body) { const { data: { session } } = await db.auth.getSession(); const token = session?.access_token ?? ''; const res = await fetch(SUPA_URL + '/functions/v1/parse-lar', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await res.json(); if (!res.ok) return { error: result.error || `HTTP ${res.status}` }; return result; } // Render a successful LAR import summary (used by both flows). function larSummaryHtml(result, filename) { const s = result.parsed_summary || {}; const fmt = (n) => n != null ? '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'; return `
✓ LAR imported${filename ? ' — ' + filename : ''}
Debtor: ${s.debtor_name || '—'}
Loan account #: ${s.loan_account_no || '—'}
Principal balance: ${fmt(s.principal_balance)}
Total due: ${fmt(s.total_due)}
NADA value: ${fmt(s.nada_value)}
Matter ${result.action} · Financial snapshot ${result.financial_snapshot}${result.match_reason ? ' · matched by ' + result.match_reason : ''}
`; } // Render the duplicate-review UI when the edge function returns needs_review. function renderLarCandidatesHtml(result, filename) { const s = result.parsed_summary || {}; const candidates = result.candidates || []; const candHtml = candidates.map(c => { const sim = c.similarity != null ? ` (${Math.round(c.similarity * 100)}% match)` : ''; const addr = c.debtor_address ? `
${escapeHtml(c.debtor_address)}
` : ''; const loan = c.loan_account_no ? `
Loan #${escapeHtml(c.loan_account_no)}
` : ''; const dob = c.dob ? `
DOB ${c.dob}${c.ssn_last4 ? ' · SSN •••-••-' + c.ssn_last4 : ''}
` : ''; return `
${escapeHtml(c.debtor_name || '')} ${escapeHtml(c.our_file_no || 'no file #')}${sim}
${addr}${loan}${dob}
`; }).join(''); return `
⚠ Possible duplicate detected${filename ? ' — ' + filename : ''}
The LAR is for ${escapeHtml(s.debtor_name || '')}${s.loan_account_no ? ' · loan #' + escapeHtml(s.loan_account_no) : ''}. We found existing matter(s) that might be the same person:
${candHtml}
`; } // Re-call parse-lar with a chosen matter_id (user picked an existing matter from candidates) async function larRetryWithMatterId(matterId) { const pending = window._pendingLarUpload; if (!pending) { toast('LAR data lost; please re-upload.', 'error'); return; } document.getElementById('lar-import-result').innerHTML = `
Updating matter…
`; const result = await callParseLar({ ...pending, matter_id: matterId, }); if (result.error) { document.getElementById('lar-import-result').innerHTML = `
Import failed
${result.error}
`; return; } document.getElementById('lar-import-result').innerHTML = ` ${larSummaryHtml(result, pending.filename)}
`; delete window._pendingLarUpload; await loadMatters(); renderImportHistory(); toast('LAR imported into existing matter'); } // Re-call parse-lar with force_create:true (user said none of the candidates match) async function larRetryForceCreate() { const pending = window._pendingLarUpload; if (!pending) { toast('LAR data lost; please re-upload.', 'error'); return; } if (!confirm('Create a new matter for this LAR? The candidate matters above will be left unchanged.')) return; document.getElementById('lar-import-result').innerHTML = `
Creating new matter…
`; const result = await callParseLar({ ...pending, force_create: true, }); if (result.error) { document.getElementById('lar-import-result').innerHTML = `
Import failed
${result.error}
`; return; } document.getElementById('lar-import-result').innerHTML = ` ${larSummaryHtml(result, pending.filename)}
`; delete window._pendingLarUpload; await loadMatters(); renderImportHistory(); toast('New matter created from LAR'); } function larCancel() { delete window._pendingLarUpload; document.getElementById('lar-import-result').innerHTML = ''; document.getElementById('lar-import-result').style.display = 'none'; } // Small HTML-escape helper function escapeHtml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ============================================================ // LAR review/edit modal // ============================================================ // // Shared by two flows: // 1. Import LAR (from Import page or matter detail) → preview parse → user reviews/edits → save // 2. Edit Loan & Borrower (from matter detail) → load existing matter data → user edits → save // // State stash on `window`: // _larReviewState = { // mode: 'import' | 'edit', // matter: {...editable matter fields...}, // financials: {...editable financial fields...}, // // For 'import' mode only: // lar_text, client_code, our_file_no, box_folder_id, box_file_id, filename, // match_matter_id, // if a strong match was found server-side // // For 'edit' mode only: // matter_id, // matter to update directly // } // Field groups: [label, key, type, ...inputAttrs] // Types: 'text', 'date', 'number-money', 'number-int', 'select' const LAR_MATTER_FIELDS = [ // Borrower section { group: 'Borrower', key: 'debtor_name', label: 'Debtor name', type: 'text', placeholder: 'Last, First' }, { group: 'Borrower', key: 'co_debtor_name', label: 'Co-debtor name', type: 'text' }, { group: 'Borrower', key: 'dob', label: 'DOB', type: 'date' }, { group: 'Borrower', key: 'ssn_last4', label: 'SSN (last 4)', type: 'text', maxlength: 4, pattern: '\\d{4}' }, { group: 'Borrower', key: 'co_dob', label: 'Co-debtor DOB', type: 'date' }, { group: 'Borrower', key: 'co_ssn_last4', label: 'Co-debtor SSN (last 4)', type: 'text', maxlength: 4, pattern: '\\d{4}' }, { group: 'Borrower', key: 'debtor_address', label: 'Mailing address', type: 'text' }, { group: 'Borrower', key: 'mh_address', label: 'Property/MH address', type: 'text' }, // Loan section { group: 'Loan', key: 'loan_account_no', label: 'Loan account #', type: 'text', mono: true }, { group: 'Loan', key: 'loan_type', label: 'Loan type', type: 'select', options: ['', 'Chattel', 'Land Plus', 'Land Home'] }, { group: 'Loan', key: 'date_of_loan', label: 'Date of loan', type: 'date' }, { group: 'Loan', key: 'original_amf', label: 'Original AMF', type: 'number-money' }, { group: 'Loan', key: 'mhp_name', label: 'MHP name', type: 'text' }, { group: 'Loan', key: 'reason_for_default', label: 'Reason for default', type: 'textarea' }, { group: 'Loan', key: 'requested_by', label: 'Requested by', type: 'text' }, { group: 'Loan', key: 'requested_date', label: 'Requested date', type: 'date' }, // Manufactured home section { group: 'Home', key: 'home_year', label: 'Year', type: 'number-int' }, { group: 'Home', key: 'home_make', label: 'Make', type: 'text' }, { group: 'Home', key: 'home_model', label: 'Model', type: 'text' }, { group: 'Home', key: 'home_size', label: 'Size', type: 'text', placeholder: 'DW 28x72' }, { group: 'Home', key: 'home_serial_no', label: 'Serial #', type: 'text', mono: true }, ]; const LAR_FINANCIAL_FIELDS = [ { group: 'Balances', key: 'principal_balance', label: 'Principal balance', type: 'number-money' }, { group: 'Balances', key: 'payoff_amount', label: 'Payoff amount', type: 'number-money' }, { group: 'Balances', key: 'per_diem', label: 'Per diem', type: 'number-money' }, { group: 'Balances', key: 'amount_past_due', label: 'Amount past due', type: 'number-money' }, { group: 'Balances', key: 'total_due', label: 'Total due', type: 'number-money' }, { group: 'Balances', key: 'nada_value', label: 'NADA value', type: 'number-money' }, { group: 'Charges', key: 'late_charges', label: 'Late charges', type: 'number-money' }, { group: 'Charges', key: 'nsf_fees', label: 'NSF fees', type: 'number-money' }, { group: 'Charges', key: 'other_fees', label: 'Other fees', type: 'number-money' }, { group: 'Charges', key: 'suspense', label: 'Suspense', type: 'number-money' }, { group: 'Payment', key: 'pi_payment', label: 'P&I payment', type: 'number-money' }, { group: 'Payment', key: 'escrow_payment', label: 'Escrow payment', type: 'number-money' }, { group: 'Payment', key: 'total_payment', label: 'Total payment', type: 'number-money' }, { group: 'Payment', key: 'due_date', label: 'Due date', type: 'date' }, { group: 'Payment', key: 'last_paid_date', label: 'Last paid date', type: 'date' }, { group: 'Payment', key: 'nod_expired_date', label: 'NOD expired date', type: 'date' }, ]; function renderLarFieldGroup(title, fields, values) { const inputs = fields.map(f => { const v = values[f.key]; const id = `lar-rev-${f.key}`; const monoStyle = f.mono ? 'font-family:var(--font-mono);' : ''; const valStr = v == null ? '' : escapeHtml(String(v)); let input = ''; if (f.type === 'textarea') { input = ``; } else if (f.type === 'date') { input = ``; } else if (f.type === 'number-money') { const numVal = v == null ? '' : Number(v).toFixed(2); input = ``; } else if (f.type === 'number-int') { input = ``; } else { const attrs = [ f.placeholder ? `placeholder="${escapeHtml(f.placeholder)}"` : '', f.maxlength ? `maxlength="${f.maxlength}"` : '', f.pattern ? `pattern="${f.pattern}"` : '', ].filter(Boolean).join(' '); input = ``; } return `
${input}
`; }).join(''); // Group fields by their `group` sub-property const byGroup = {}; fields.forEach(f => { if (!byGroup[f.group]) byGroup[f.group] = []; byGroup[f.group].push(f); }); const groupHtml = Object.entries(byGroup).map(([gName, gFields]) => { const gInputs = gFields.map(f => { const v = values[f.key]; const id = `lar-rev-${f.key}`; const monoStyle = f.mono ? 'font-family:var(--font-mono);' : ''; const valStr = v == null ? '' : escapeHtml(String(v)); let input = ''; if (f.type === 'textarea') { input = ``; } else if (f.type === 'date') { input = ``; } else if (f.type === 'number-money') { const numVal = v == null || v === '' ? '' : Number(v).toFixed(2); input = ``; } else if (f.type === 'number-int') { input = ``; } else if (f.type === 'select') { // Render a dropdown from f.options. Empty option means "(unset)". const optsHtml = (f.options || []).map(o => { const lbl = o === '' ? '— select —' : o; const sel = String(v ?? '') === String(o) ? ' selected' : ''; return ``; }).join(''); input = ``; } else { const attrs = [ f.placeholder ? `placeholder="${escapeHtml(f.placeholder)}"` : '', f.maxlength ? `maxlength="${f.maxlength}"` : '', f.pattern ? `pattern="${f.pattern}"` : '', ].filter(Boolean).join(' '); input = ``; } const colSpan = f.type === 'textarea' ? 'grid-column:1 / -1;' : ''; return `
${input}
`; }).join(''); return `
${escapeHtml(gName)}
${gInputs}
`; }).join(''); return `

${escapeHtml(title)}

${groupHtml}
`; } function populateLarReviewForm() { const state = window._larReviewState; if (!state) return; const html = renderLarFieldGroup('Matter', LAR_MATTER_FIELDS, state.matter || {}) + renderLarFieldGroup('Financial snapshot', LAR_FINANCIAL_FIELDS, state.financials || {}); document.getElementById('lar-review-form').innerHTML = html; } function readLarReviewForm() { const matter = {}; for (const f of LAR_MATTER_FIELDS) { const el = document.getElementById(`lar-rev-${f.key}`); if (!el) continue; let v = el.value; if (typeof v === 'string') v = v.trim(); if (v === '') v = null; if (v != null && (f.type === 'number-money' || f.type === 'number-int')) { v = Number(v); if (isNaN(v)) v = null; } matter[f.key] = v; } const financials = {}; for (const f of LAR_FINANCIAL_FIELDS) { const el = document.getElementById(`lar-rev-${f.key}`); if (!el) continue; let v = el.value; if (typeof v === 'string') v = v.trim(); if (v === '') v = null; if (v != null && (f.type === 'number-money' || f.type === 'number-int')) { v = Number(v); if (isNaN(v)) v = null; } financials[f.key] = v; } return { matter, financials }; } function openLarReview(state) { window._larReviewState = state; document.getElementById('lar-review-title').textContent = state.mode === 'edit' ? 'Edit loan & borrower data' : 'Review LAR data before saving'; document.getElementById('lar-review-intro').textContent = state.mode === 'edit' ? 'Edit any field below. Fields left blank will be cleared (set to NULL) on the matter.' : 'Review the data extracted from the LAR. Edit anything that\'s wrong, fill in blanks, then click Save.'; populateLarReviewForm(); document.getElementById('modal-lar-review').classList.add('open'); } function closeLarReview() { document.getElementById('modal-lar-review').classList.remove('open'); delete window._larReviewState; } async function saveLarReview() { const state = window._larReviewState; if (!state) return; const edited = readLarReviewForm(); const btn = document.getElementById('lar-review-save-btn'); btn.disabled = true; btn.textContent = 'Saving…'; try { if (state.mode === 'edit') { // Direct DB update — no edge function needed. The user is editing an existing matter, // not importing an LAR. const matterUpdate = { ...edited.matter }; // Clean type coercion: matter table expects null for blanks const { error } = await db.from('matters').update(matterUpdate).eq('id', state.matter_id); if (error) throw new Error(error.message); // Save the financial snapshot only if user filled in any financial fields const finVals = edited.financials; const hasFinancials = Object.values(finVals).some(v => v != null && v !== ''); if (hasFinancials) { const finRow = { matter_id: state.matter_id, snapshot_date: new Date().toISOString().split('T')[0], source_type: 'manual', imported_by: currentUser?.email || null, ...finVals, }; const { error: finErr } = await db.from('matter_financials').insert(finRow); if (finErr) throw new Error('Saved matter, but financial snapshot failed: ' + finErr.message); } closeLarReview(); toast('Matter updated'); await loadMatters(); await openDetail(state.matter_id); } else { // Import mode — call edge function with manual_data const result = await callParseLar({ lar_text: state.lar_text, client_code: state.client_code, our_file_no: state.our_file_no, box_folder_id: state.box_folder_id, box_file_id: state.box_file_id, matter_id: state.match_matter_id || state.matter_id || null, manual_data: edited, force_create: state.force_create || false, }); if (result.error) throw new Error(result.error); // If the server says "needs_review", surface candidates (rare in this flow but possible // if user edited the name to something that fuzzy-matches a different existing matter). if (result.status === 'needs_review') { closeLarReview(); // Re-display the candidates UI on the import page const target = state.context === 'import-page' ? 'lar-import-result' : null; if (target) { document.getElementById(target).style.display = 'block'; document.getElementById(target).innerHTML = renderLarCandidatesHtml(result, state.filename); } return; } closeLarReview(); // Show success on whichever surface the user came from if (state.context === 'import-page') { const matterId = result.matter_id; document.getElementById('lar-import-result').style.display = 'block'; document.getElementById('lar-import-result').innerHTML = ` ${larSummaryHtml(result, state.filename)}
`; delete window._pendingLarUpload; await loadMatters(); renderImportHistory(); toast(`LAR imported (matter ${result.action})`); } else { // From matter detail await loadMatters(); await openDetail(result.matter_id); toast('LAR imported'); } } } catch (e) { toast('Error: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Save'; } } // Open the review modal for direct edit of an existing matter (no LAR involved) function openMatterFieldsEdit() { if (!currentMatterId) return; const m = allMatters.find(x => x.id === currentMatterId); if (!m) return; // Pull the latest financial snapshot so the user can edit it too // (We could also let them add a new one — but for editing it's clearer to seed with the latest.) // For now, leave financials blank in edit mode — it's a NEW snapshot they're adding. const matterFields = {}; for (const f of LAR_MATTER_FIELDS) matterFields[f.key] = m[f.key]; openLarReview({ mode: 'edit', matter_id: currentMatterId, matter: matterFields, financials: {}, // empty: any non-blank fields create a new "manual" snapshot }); } async function renderAdminUsers() { const { data: users, error } = await db.from('user_profiles').select('id, email, full_name, initials, role, active, created_at'); if (error) { document.getElementById('admin-users').innerHTML = `
Unable to load users: ${error.message}
`; return; } if (!users || !users.length) { document.getElementById('admin-users').innerHTML = '
No users found
'; return; } document.getElementById('admin-users').innerHTML = ` ${users.map(u => ``).join('')}
NameEmailRoleActions
${u.full_name || u.initials || '—'} ${u.email || '—'} ${u.role}
`; } async function sendReset(userId, emailArg) { const email = emailArg || prompt('Enter email address for password reset:'); if (!email) return; const { error } = await db.auth.resetPasswordForEmail(email, { // Use the current app URL so the recovery link returns the user here. Make sure // this URL is added to Supabase's allowed redirect URLs (Auth → URL Configuration). redirectTo: window.location.origin + window.location.pathname, }); if (error) { toast('Error: ' + error.message, 'error'); return; } toast('Password reset email sent to ' + email); } async function toggleUserActive(userId, currentlyActive) { const { error } = await db.from('user_profiles').update({ active: !currentlyActive }).eq('id', userId); if (error) { toast('Error: ' + error.message, 'error'); return; } toast(currentlyActive ? 'User disabled' : 'User enabled'); renderAdminUsers(); } async function createUser() { const email = document.getElementById('u-email').value.trim(); const pw = document.getElementById('u-pw').value; const role = document.getElementById('u-role').value; if (!email || !pw) { toast('Email and password required', 'error'); return; } const { data, error } = await db.auth.signUp({ email, password: pw }); if (error) { toast('Error: ' + error.message, 'error'); return; } if (data.user) { await db.from('user_profiles').upsert({ id: data.user.id, initials: email.split('@')[0].toUpperCase().substring(0, 3), role: role }); toast('User created: ' + email); closeModal('modal-user'); renderAdminUsers(); } } // ========== Password recovery & forgot-password flow ========== // Tracks whether we're inside a password-recovery session — set true when Supabase // fires PASSWORD_RECOVERY (which happens after user clicks the email link). When true, // we show the "Set new password" panel instead of starting the main app, even though // the user is technically signed in. let _inPasswordRecovery = false; function showLoginPanel(ev) { if (ev) ev.preventDefault(); document.getElementById('auth-login-panel').style.display = ''; document.getElementById('auth-forgot-panel').style.display = 'none'; document.getElementById('auth-recovery-panel').style.display = 'none'; document.getElementById('auth-error').style.display = 'none'; document.getElementById('forgot-error').style.display = 'none'; document.getElementById('forgot-success').style.display = 'none'; } function showForgotPassword(ev) { if (ev) ev.preventDefault(); document.getElementById('auth-login-panel').style.display = 'none'; document.getElementById('auth-forgot-panel').style.display = ''; document.getElementById('auth-recovery-panel').style.display = 'none'; // Pre-fill from login email field if present const loginEmail = document.getElementById('login-email').value; if (loginEmail) document.getElementById('forgot-email').value = loginEmail; document.getElementById('forgot-email').focus(); } function showRecoveryPanel() { document.getElementById('auth-login-panel').style.display = 'none'; document.getElementById('auth-forgot-panel').style.display = 'none'; document.getElementById('auth-recovery-panel').style.display = ''; document.getElementById('auth-screen').style.display = 'flex'; document.getElementById('app').style.display = 'none'; document.getElementById('recovery-pw').focus(); } async function requestPasswordReset() { const email = document.getElementById('forgot-email').value.trim(); const errEl = document.getElementById('forgot-error'); const okEl = document.getElementById('forgot-success'); errEl.style.display = 'none'; okEl.style.display = 'none'; if (!email) { errEl.textContent = 'Please enter your email address.'; errEl.style.display = 'block'; return; } const { error } = await db.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + window.location.pathname, }); if (error) { errEl.textContent = error.message; errEl.style.display = 'block'; return; } okEl.innerHTML = `If an account exists for ${email.replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]))}, a reset link has been sent.
Check your inbox (and spam folder).`; okEl.style.display = 'block'; } async function saveNewPassword() { const pw = document.getElementById('recovery-pw').value; const pw2 = document.getElementById('recovery-pw2').value; const errEl = document.getElementById('recovery-error'); errEl.style.display = 'none'; if (!pw || pw.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; errEl.style.display = 'block'; return; } if (pw !== pw2) { errEl.textContent = 'Passwords do not match.'; errEl.style.display = 'block'; return; } const { data, error } = await db.auth.updateUser({ password: pw }); if (error) { errEl.textContent = error.message; errEl.style.display = 'block'; return; } // Password updated successfully. Clear the URL hash (which contained the recovery // token) and start the app. The user is already signed in via the recovery session. if (window.location.hash) { history.replaceState(null, '', window.location.pathname + window.location.search); } _inPasswordRecovery = false; currentUser = data.user; // Clear inputs document.getElementById('recovery-pw').value = ''; document.getElementById('recovery-pw2').value = ''; startApp(); toast('Password updated. You\'re now signed in.'); } // ========== Auth event listener ========== // PASSWORD_RECOVERY fires when Supabase processes the recovery token in the URL hash // after the user clicks the email link. We hijack normal startup to show the // "Set new password" panel instead of dropping them into the app. db.auth.onAuthStateChange((event, session) => { if (event === 'PASSWORD_RECOVERY') { _inPasswordRecovery = true; showRecoveryPanel(); } }); // Check existing session on load. Skip auto-startApp if we're in recovery mode — // we need the user to set a new password first. db.auth.getSession().then(({ data: { session } }) => { // Detect recovery flow by URL hash even before onAuthStateChange fires if (window.location.hash && /(?:^|[#&])type=recovery(?:&|$)/.test(window.location.hash)) { _inPasswordRecovery = true; showRecoveryPanel(); return; } if (_inPasswordRecovery) return; if (session) { currentUser = session.user; startApp(); } }); // Handle Enter key on login and recovery forms document.getElementById('login-pw').addEventListener('keydown', e => { if (e.key === 'Enter') signIn(); }); document.getElementById('forgot-email').addEventListener('keydown', e => { if (e.key === 'Enter') requestPasswordReset(); }); document.getElementById('recovery-pw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveNewPassword(); });