From 389f924300fa36d072351867bcc80e9b3ac85838 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 00:07:48 +0000 Subject: [PATCH] feat: add rent/delinquency tracking and document links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps stood out as the next-highest-value additions for a beginner PM company on top of the lease tracking already shipped: nobody could see who owed money, and there was nowhere to point at the actual lease PDF, W9, or COI once you knew it existed. Rent collection: - Tenants gain a balanceDue field and a "Payment" quick action on their card that records an amount, reduces the balance, stamps lastPaymentDate, and logs it to the audit trail. - A past-due chip appears on any tenant with a positive balance (escalating to danger once they owe a full month's rent), reusing the same chip styling as the lease-status feature. - Launch Plan risk queue surfaces a "Delinquency" item with the tenant count and total dollars outstanding across the portfolio. Document links: - Properties, tenants, and vendors can each carry one external document link (lease PDF, COI, W9, deed) — just a URL field, no file upload or storage backend, consistent with the zero-backend philosophy. - isSafeUrl() only accepts http(s):// links and is enforced twice: once on save (client-side form validation) and again at render time, since portfolio records can also arrive via JSON import or Team Sync, neither of which goes through the form's validation. Verified live that a javascript: URL is rejected outright rather than ever reaching an . Added getDelinquencyStatus() and isSafeUrl() as tested pure functions in utils.js/utils.cjs. Demo company, "Add Starter Example", and the templates.cjs mirror all carry sample balances/document links so the features are visible without manual entry. Verified end-to-end in-browser: doc links render and are clickable, a malicious javascript: URL is rejected with a clear alert instead of being saved, the payment flow correctly reduces an outstanding balance and updates the chip, the Launch Plan risk queue picks up delinquent tenants with the right dollar total, and the demo company shows both lease and delinquency chips plus document links out of the box. Full test suite (73 passing) and production build both clean, zero console errors during the live run. --- README.md | 10 +++- css/style.css | 15 ++++++ js/__tests__/utils.test.js | 51 +++++++++++++++++++++ js/app.js | 2 + js/launchPlan.js | 15 +++++- js/state.js | 1 + js/templates.cjs | 6 +-- js/templates.js | 4 ++ js/utils.cjs | 14 ++++++ js/utils.js | 20 ++++++++ js/views/portfolio.js | 93 ++++++++++++++++++++++++++++++++++++-- 11 files changed, 220 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5b3c108..fe95650 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,13 @@ Each work order captures: property, unit, tenant/resident, issue title, notes, p ### Portfolio & Data Quality -Track properties, tenants, and vendors in the **Portfolio tab**. Tenant records include monthly rent and lease start/end dates, with a color-coded chip that flags leases renewing within 60 days or already expired — surfaced both on the tenant card and in the Launch Plan's risk queue. The launch dashboard also runs four data-quality checks (missing owner, missing phone/email, open work orders without assignees) and feeds a readiness score before you export or hand off. +Track properties, tenants, and vendors in the **Portfolio tab**. Tenant records include monthly rent and lease start/end dates, with a color-coded chip that flags leases renewing within 60 days or already expired — surfaced both on the tenant card and in the Launch Plan's risk queue. + +**Rent collection.** Each tenant tracks a balance due. Click **Payment** on a tenant card to record what came in — it reduces the balance, stamps the payment date, and logs it to the audit trail. Any tenant with an outstanding balance shows a past-due chip (escalating to a stronger warning once they owe a full month's rent) and rolls up into a Launch Plan risk item showing total dollars outstanding across the portfolio. + +**Document links.** Properties, tenants, and vendors can each carry a link to an external document — a signed lease, a certificate of insurance, a deed — pasted from wherever you already store files (Google Drive, Dropbox, etc.). No file upload or storage backend involved; only `http(s)://` links are accepted and validated both on save and on render. + +The launch dashboard also runs four data-quality checks (missing owner, missing phone/email, open work orders without assignees) and feeds a readiness score before you export or hand off. --- @@ -218,7 +224,7 @@ Includes a **Load Demo Company** option for exploring a realistic sample workspa ### Audit Log -Every meaningful change (owner assignments, status and priority changes, task renames, auto-assign runs, work order advances and deletions, imports, paste-state operations, Team Sync connect/disconnect/sync events) is recorded in an append-only log. Capped at 500 entries. View or clear it from the stats bar. +Every meaningful change (owner assignments, status and priority changes, task renames, auto-assign runs, work order advances and deletions, imports, paste-state operations, Team Sync connect/disconnect/sync events, rent payments recorded) is recorded in an append-only log. Capped at 500 entries. View or clear it from the stats bar. --- diff --git a/css/style.css b/css/style.css index db0c3d0..045afbc 100644 --- a/css/style.css +++ b/css/style.css @@ -3498,6 +3498,21 @@ body { border: 1px solid #e57373; } +.doc-link { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 6px; + font-size: 12px; + font-weight: 600; + color: var(--focus, #1976d2); + text-decoration: none; +} + +.doc-link:hover { + text-decoration: underline; +} + .portfolio-edit-btn, .portfolio-delete-btn { padding: 6px 9px; diff --git a/js/__tests__/utils.test.js b/js/__tests__/utils.test.js index 3a6e850..e147d18 100644 --- a/js/__tests__/utils.test.js +++ b/js/__tests__/utils.test.js @@ -207,6 +207,57 @@ describe('getLeaseStatus', () => { }); }); +describe('getDelinquencyStatus', () => { + test('tenant with no balance due returns null', () => { + expect(utils.getDelinquencyStatus({ rent: 1200 })).toBeNull(); + }); + + test('zero or negative balance returns null', () => { + expect(utils.getDelinquencyStatus({ rent: 1200, balanceDue: 0 })).toBeNull(); + expect(utils.getDelinquencyStatus({ rent: 1200, balanceDue: -50 })).toBeNull(); + }); + + test('balance less than a full month rent is tone warn', () => { + const status = utils.getDelinquencyStatus({ rent: 1200, balanceDue: 400 }); + expect(status.tone).toBe('warn'); + expect(status.label).toBe('$400.00 past due'); + }); + + test('balance at or above a full month rent is tone danger', () => { + const status = utils.getDelinquencyStatus({ rent: 1200, balanceDue: 1200 }); + expect(status.tone).toBe('danger'); + const status2 = utils.getDelinquencyStatus({ rent: 1200, balanceDue: 2500 }); + expect(status2.tone).toBe('danger'); + }); + + test('balance due with no rent on file is still tone warn (no month to compare against)', () => { + const status = utils.getDelinquencyStatus({ balanceDue: 5000 }); + expect(status.tone).toBe('warn'); + }); +}); + +describe('isSafeUrl', () => { + test('accepts http and https URLs', () => { + expect(utils.isSafeUrl('https://example.com/lease.pdf')).toBe(true); + expect(utils.isSafeUrl('http://example.com')).toBe(true); + }); + + test('rejects javascript: URLs', () => { + expect(utils.isSafeUrl('javascript:alert(1)')).toBe(false); + }); + + test('rejects data: URLs', () => { + expect(utils.isSafeUrl('data:text/html,')).toBe(false); + }); + + test('rejects relative paths and empty values', () => { + expect(utils.isSafeUrl('/some/path')).toBe(false); + expect(utils.isSafeUrl('')).toBe(false); + expect(utils.isSafeUrl(null)).toBe(false); + expect(utils.isSafeUrl(undefined)).toBe(false); + }); +}); + describe('isTaskOverdue (integration)', () => { test('past dueDate with todo status is overdue', () => { const task = { dueDate: '2020-01-01', status: 'todo' }; diff --git a/js/app.js b/js/app.js index 792ce0b..76ee225 100644 --- a/js/app.js +++ b/js/app.js @@ -68,6 +68,7 @@ import { renderPortfolioView, commitAddProperty, commitAddTenant, commitAddVendor, deleteProperty, deleteTenant, deleteVendor, addStarterExample, startEditProperty, startEditTenant, startEditVendor, cancelPortfolioEdit, + recordTenantPayment, } from './views/portfolio.js'; import { exportJSON, exportCSV, exportPropertiesCSV, exportTenantsCSV, @@ -312,6 +313,7 @@ Object.assign(window, { startEditTenant, startEditVendor, cancelPortfolioEdit, + recordTenantPayment, // Import/export tools: let users keep ownership of their data with plain files // or clipboard transfer instead of needing a hosted database. exportJSON, diff --git a/js/launchPlan.js b/js/launchPlan.js index 2dc317d..669162d 100644 --- a/js/launchPlan.js +++ b/js/launchPlan.js @@ -7,7 +7,7 @@ import { COMPANY_KEY, OPS_PROFILE_KEY, applyCompanyName, applyOpsProfile, saveTeamData, saveWorkOrders, savePortfolio, saveToStorage, logAudit, } from './storage.js'; -import { escapeHtml, _slugify, getLeaseStatus } from './utils.js'; +import { escapeHtml, _slugify, getLeaseStatus, getDelinquencyStatus, formatCurrency } from './utils.js'; import { ROLE_TEMPLATES, buildDemoWorkspace } from './templates.js'; // Maintainer note: @@ -824,6 +824,8 @@ function getOperationsSnapshot() { const status = getLeaseStatus(tenant); return status && (status.tone === 'warn' || status.tone === 'danger'); }).length; + const delinquentTenants = portfolio.tenants.filter(tenant => getDelinquencyStatus(tenant)).length; + const totalBalanceDue = portfolio.tenants.reduce((sum, tenant) => sum + Number(tenant.balanceDue || 0), 0); return { totalTasks, @@ -841,6 +843,8 @@ function getOperationsSnapshot() { employeeCount: teamData.employees.length, isStarterTeam: isStarterTeam(), leasesNeedingAttention, + delinquentTenants, + totalBalanceDue, }; } @@ -970,6 +974,15 @@ function getRiskQueue(snapshot) { onclick: "switchView('portfolio', document.getElementById('portfolio-tab'))", }); } + if (snapshot.delinquentTenants > 0) { + risks.push({ + label: 'Delinquency', + title: `${snapshot.delinquentTenants} tenant${snapshot.delinquentTenants === 1 ? '' : 's'} behind on rent — ${formatCurrency(snapshot.totalBalanceDue)} outstanding`, + detail: 'Follow up before balances grow past a full month behind.', + tone: 'danger', + onclick: "switchView('portfolio', document.getElementById('portfolio-tab'))", + }); + } if (snapshot.isStarterTeam) { risks.push({ label: 'Team', diff --git a/js/state.js b/js/state.js index c619df1..4a1bf05 100644 --- a/js/state.js +++ b/js/state.js @@ -102,6 +102,7 @@ export const AUDIT_LABELS = { portfolio_vendor_added: 'Vendor Added', portfolio_vendor_deleted: 'Vendor Deleted', portfolio_vendor_updated: 'Vendor Updated', + portfolio_payment_recorded: 'Payment Recorded', wo_recurring: 'Recurring Template Applied', starter_example_added: 'Starter Example Added', demo_company_loaded: 'Demo Company Loaded', diff --git a/js/templates.cjs b/js/templates.cjs index 717c914..66b1cc9 100644 --- a/js/templates.cjs +++ b/js/templates.cjs @@ -94,9 +94,9 @@ function buildDemoWorkspace(now = new Date()) { configuredAt: now.toISOString(), }, portfolio: { - properties: [{ id: propertyId, name: 'Oak Street Duplex', units: 2, owner: 'Rivera Family LLC', notes: 'Owner prefers text updates before non-urgent repairs.', createdAt: now.toISOString() }], - tenants: [{ id: `demo-tenant-${stamp}`, name: 'Maya Chen', propertyId, unit: '2B', status: 'active', phone: '(555) 010-2040', email: 'maya.chen@example.com', rent: 1450, leaseStart: _isoDateOffset(now, -320), leaseEnd: _isoDateOffset(now, 45), createdAt: now.toISOString() }], - vendors: [{ id: `demo-vendor-${stamp}`, name: 'Ace Plumbing', trade: 'Plumbing', phone: '(555) 010-1188', email: 'dispatch@aceplumbing.example', createdAt: now.toISOString() }], + properties: [{ id: propertyId, name: 'Oak Street Duplex', units: 2, owner: 'Rivera Family LLC', notes: 'Owner prefers text updates before non-urgent repairs.', documentUrl: 'https://example.com/documents/oak-street-deed.pdf', createdAt: now.toISOString() }], + tenants: [{ id: `demo-tenant-${stamp}`, name: 'Maya Chen', propertyId, unit: '2B', status: 'active', phone: '(555) 010-2040', email: 'maya.chen@example.com', rent: 1450, leaseStart: _isoDateOffset(now, -320), leaseEnd: _isoDateOffset(now, 45), balanceDue: 450, documentUrl: 'https://example.com/documents/maya-chen-lease.pdf', createdAt: now.toISOString() }], + vendors: [{ id: `demo-vendor-${stamp}`, name: 'Ace Plumbing', trade: 'Plumbing', phone: '(555) 010-1188', email: 'dispatch@aceplumbing.example', documentUrl: 'https://example.com/documents/ace-plumbing-coi.pdf', createdAt: now.toISOString() }], }, workOrders: [{ id: `demo-wo-${stamp}`, property: 'Oak Street Duplex', unit: '2B', tenant: 'Maya Chen', title: 'Kitchen sink leak', notes: 'Resident reports slow drip under sink. Confirm access window before dispatch.', priority: 'high', status: 'submitted', assignee: 'Maintenance Coordinator', vendor: 'Ace Plumbing', dueDate: null, cost: 0, createdAt: now.toISOString(), updatedAt: now.toISOString() }], }; diff --git a/js/templates.js b/js/templates.js index 72c9f03..e29f00b 100644 --- a/js/templates.js +++ b/js/templates.js @@ -101,6 +101,7 @@ export function buildDemoWorkspace(now = new Date()) { units: 2, owner: 'Rivera Family LLC', notes: 'Owner prefers text updates before non-urgent repairs.', + documentUrl: 'https://example.com/documents/oak-street-deed.pdf', createdAt: now.toISOString(), }, ], @@ -116,6 +117,8 @@ export function buildDemoWorkspace(now = new Date()) { rent: 1450, leaseStart: _isoDateOffset(now, -320), leaseEnd: _isoDateOffset(now, 45), + balanceDue: 450, + documentUrl: 'https://example.com/documents/maya-chen-lease.pdf', createdAt: now.toISOString(), }, ], @@ -126,6 +129,7 @@ export function buildDemoWorkspace(now = new Date()) { trade: 'Plumbing', phone: '(555) 010-1188', email: 'dispatch@aceplumbing.example', + documentUrl: 'https://example.com/documents/ace-plumbing-coi.pdf', createdAt: now.toISOString(), }, ], diff --git a/js/utils.cjs b/js/utils.cjs index baf5fbe..8ff3eef 100644 --- a/js/utils.cjs +++ b/js/utils.cjs @@ -99,6 +99,18 @@ function getLeaseStatus(tenant, todayISO = getTodayISO()) { return { tone: 'neutral', label: `Lease ends ${formatDueChip(tenant.leaseEnd)}`, days }; } +function getDelinquencyStatus(tenant) { + const balance = Number(tenant?.balanceDue || 0); + if (!(balance > 0)) return null; + const rent = Number(tenant?.rent || 0); + const tone = rent > 0 && balance >= rent ? 'danger' : 'warn'; + return { tone, label: `${formatCurrency(balance)} past due`, balance }; +} + +function isSafeUrl(url) { + return typeof url === 'string' && /^https?:\/\//i.test(url.trim()); +} + module.exports = { escapeHtml, jsonAttr, @@ -114,4 +126,6 @@ module.exports = { formatWODate, formatCurrency, getLeaseStatus, + getDelinquencyStatus, + isSafeUrl, }; diff --git a/js/utils.js b/js/utils.js index 9e4ab57..a92b1bc 100644 --- a/js/utils.js +++ b/js/utils.js @@ -114,6 +114,24 @@ function getLeaseStatus(tenant, todayISO = getTodayISO()) { return { tone: 'neutral', label: `Lease ends ${formatDueChip(tenant.leaseEnd)}`, days }; } +// Returns { tone, label, balance } if a tenant has a positive outstanding +// balance, or null if they're current. Tone escalates to danger once the +// balance reaches a full month's rent. +function getDelinquencyStatus(tenant) { + const balance = Number(tenant?.balanceDue || 0); + if (!(balance > 0)) return null; + const rent = Number(tenant?.rent || 0); + const tone = rent > 0 && balance >= rent ? 'danger' : 'warn'; + return { tone, label: `${formatCurrency(balance)} past due`, balance }; +} + +// Only allows absolute http(s) links — this gets embedded as an , so +// rejecting anything else (javascript:, data:, relative paths) up front means +// callers never need to think about scheme-based injection at render time. +function isSafeUrl(url) { + return typeof url === 'string' && /^https?:\/\//i.test(url.trim()); +} + export { escapeHtml, jsonAttr, @@ -129,4 +147,6 @@ export { formatWODate, formatCurrency, getLeaseStatus, + getDelinquencyStatus, + isSafeUrl, }; diff --git a/js/views/portfolio.js b/js/views/portfolio.js index 83d402f..dc128d9 100644 --- a/js/views/portfolio.js +++ b/js/views/portfolio.js @@ -1,6 +1,6 @@ import { portfolio, setPortfolio, workOrders } from '../state.js'; import { savePortfolio, saveWorkOrders, logAudit, _showActionToast } from '../storage.js'; -import { escapeHtml, shakeInput, isValidISODate, formatCurrency, getLeaseStatus } from '../utils.js'; +import { escapeHtml, shakeInput, isValidISODate, formatCurrency, getLeaseStatus, getDelinquencyStatus, isSafeUrl } from '../utils.js'; import { renderLaunchPlan } from '../launchPlan.js'; const editState = { @@ -87,6 +87,10 @@ export function renderPortfolioView() { Notes +
@@ -142,6 +146,14 @@ export function renderPortfolioView() { Lease end + +
@@ -173,6 +185,10 @@ export function renderPortfolioView() { Email +
@@ -219,10 +235,10 @@ export function renderPortfolioView() { `; [ - 'property-name', 'property-units', 'property-owner', 'property-notes', + 'property-name', 'property-units', 'property-owner', 'property-notes', 'property-doc-url', 'tenant-name', 'tenant-property', 'tenant-unit', 'tenant-status', 'tenant-phone', 'tenant-email', - 'tenant-rent', 'tenant-lease-start', 'tenant-lease-end', - 'vendor-name', 'vendor-trade', 'vendor-phone', 'vendor-email' + 'tenant-rent', 'tenant-lease-start', 'tenant-lease-end', 'tenant-balance-due', 'tenant-doc-url', + 'vendor-name', 'vendor-trade', 'vendor-phone', 'vendor-email', 'vendor-doc-url' ].forEach(id => { const el = document.getElementById(id); if (el) el.onkeydown = e => { @@ -249,6 +265,7 @@ export function addStarterExample() { units: 2, owner: 'Rivera Family LLC', notes: 'Lockbox on side gate. Owner prefers text before non-urgent repairs.', + documentUrl: 'https://example.com/documents/oak-street-deed.pdf', createdAt: new Date().toISOString(), }; const tenant = { @@ -262,6 +279,8 @@ export function addStarterExample() { rent: 1450, leaseStart: new Date(stamp - 320 * 86400000).toISOString().slice(0, 10), leaseEnd: new Date(stamp + 45 * 86400000).toISOString().slice(0, 10), + balanceDue: 450, + documentUrl: 'https://example.com/documents/maya-chen-lease.pdf', createdAt: new Date().toISOString(), }; const vendor = { @@ -270,6 +289,7 @@ export function addStarterExample() { trade: 'Plumbing', phone: '(555) 010-1188', email: 'dispatch@aceplumbing.example', + documentUrl: 'https://example.com/documents/ace-plumbing-coi.pdf', createdAt: new Date().toISOString(), }; const workOrder = { @@ -314,12 +334,20 @@ export function commitAddProperty() { nameEl?.focus(); return; } + const docUrlEl = document.getElementById('property-doc-url'); + const docUrl = docUrlEl?.value.trim() || ''; + if (docUrl && !isSafeUrl(docUrl)) { + shakeInput(docUrlEl); + alert('Document link must be a full http:// or https:// URL.'); + return; + } const rawUnits = parseInt(document.getElementById('property-units')?.value, 10); const payload = { name, units: Number.isFinite(rawUnits) && rawUnits >= 0 ? rawUnits : 0, owner: document.getElementById('property-owner')?.value.trim() || '', notes: document.getElementById('property-notes')?.value.trim() || '', + documentUrl: docUrl, }; const existing = portfolio.properties.find(property => property.id === editState.propertyId); if (existing) { @@ -358,7 +386,16 @@ export function commitAddTenant() { return; } + const docUrlEl = document.getElementById('tenant-doc-url'); + const docUrl = docUrlEl?.value.trim() || ''; + if (docUrl && !isSafeUrl(docUrl)) { + shakeInput(docUrlEl); + alert('Document link must be a full http:// or https:// URL.'); + return; + } + const rawRent = parseFloat(document.getElementById('tenant-rent')?.value); + const rawBalance = parseFloat(document.getElementById('tenant-balance-due')?.value); const payload = { name, propertyId: document.getElementById('tenant-property')?.value || '', @@ -369,6 +406,8 @@ export function commitAddTenant() { rent: Number.isFinite(rawRent) && rawRent >= 0 ? rawRent : 0, leaseStart: isValidISODate(leaseStart) ? leaseStart : '', leaseEnd: isValidISODate(leaseEnd) ? leaseEnd : '', + balanceDue: Number.isFinite(rawBalance) && rawBalance >= 0 ? rawBalance : 0, + documentUrl: docUrl, }; const existing = portfolio.tenants.find(tenant => tenant.id === editState.tenantId); if (existing) { @@ -396,11 +435,19 @@ export function commitAddVendor() { nameEl?.focus(); return; } + const docUrlEl = document.getElementById('vendor-doc-url'); + const docUrl = docUrlEl?.value.trim() || ''; + if (docUrl && !isSafeUrl(docUrl)) { + shakeInput(docUrlEl); + alert('Document link must be a full http:// or https:// URL.'); + return; + } const payload = { name, trade: document.getElementById('vendor-trade')?.value.trim() || '', phone: document.getElementById('vendor-phone')?.value.trim() || '', email: document.getElementById('vendor-email')?.value.trim() || '', + documentUrl: docUrl, }; const existing = portfolio.vendors.find(vendor => vendor.id === editState.vendorId); if (existing) { @@ -435,6 +482,27 @@ export function deleteProperty(id) { renderLaunchPlan(); } +export function recordTenantPayment(id) { + const tenant = portfolio.tenants.find(t => t.id === id); + if (!tenant) return; + const currentBalance = Number(tenant.balanceDue || 0); + const input = prompt(`Record a payment for ${tenant.name}.\n\nCurrent balance due: ${formatCurrency(currentBalance)}\n\nPayment amount:`, currentBalance > 0 ? currentBalance : (tenant.rent || '')); + if (input === null) return; + const amount = parseFloat(input); + if (!Number.isFinite(amount) || amount <= 0) { + alert('Enter a payment amount greater than 0.'); + return; + } + tenant.balanceDue = Math.max(0, currentBalance - amount); + tenant.lastPaymentDate = new Date().toISOString().slice(0, 10); + tenant.updatedAt = new Date().toISOString(); + savePortfolio(); + logAudit('portfolio_payment_recorded', { title: tenant.name, from: currentBalance, to: tenant.balanceDue }); + renderPortfolioView(); + renderLaunchPlan(); + _showActionToast(`✓ Payment recorded for ${tenant.name}`, 'save-toast--success'); +} + export function deleteTenant(id) { const tenant = portfolio.tenants.find(t => t.id === id); if (!tenant) return; @@ -493,6 +561,14 @@ export function cancelPortfolioEdit(type) { renderPortfolioView(); } +// Re-validates the scheme at render time too, not just on save — portfolio +// records can also arrive via JSON import or Team Sync, neither of which +// goes through commitAddProperty/Tenant/Vendor's save-time validation. +function renderDocLink(url) { + if (!isSafeUrl(url)) return ''; + return `📄 Document`; +} + function renderPropertyCard(property) { return `
@@ -500,6 +576,7 @@ function renderPropertyCard(property) { ${escapeHtml(property.name)} ${Number(property.units || 0)} unit${Number(property.units || 0) === 1 ? '' : 's'}${property.owner ? ` / ${escapeHtml(property.owner)}` : ''} ${property.notes ? `${escapeHtml(property.notes)}` : ''} + ${renderDocLink(property.documentUrl)}
@@ -517,6 +594,7 @@ function renderVendorCard(vendor) { ${escapeHtml(vendor.name)} ${escapeHtml(vendor.trade || 'General vendor')} ${contact ? `${escapeHtml(contact)}` : ''} + ${renderDocLink(vendor.documentUrl)}
@@ -530,20 +608,25 @@ function renderTenantCard(tenant) { const contact = [tenant.phone, tenant.email].filter(Boolean).join(' / '); const unitLabel = tenant.unit ? `Unit ${tenant.unit}` : 'No unit recorded'; const leaseStatus = getLeaseStatus(tenant); + const delinquency = getDelinquencyStatus(tenant); return `
${escapeHtml(tenant.name)} ${escapeHtml(propertyName(tenant.propertyId))} / ${escapeHtml(unitLabel)} / ${escapeHtml(tenant.status || 'active')} ${contact ? `${escapeHtml(contact)}` : ''} - ${tenant.rent > 0 || leaseStatus ? ` + ${tenant.rent > 0 || leaseStatus || delinquency ? `
${tenant.rent > 0 ? `${escapeHtml(formatCurrency(tenant.rent))}/mo` : ''} ${leaseStatus ? `${escapeHtml(leaseStatus.label)}` : ''} + ${delinquency ? `${escapeHtml(delinquency.label)}` : ''}
` : ''} + ${tenant.lastPaymentDate ? `Last payment: ${escapeHtml(tenant.lastPaymentDate)}` : ''} + ${renderDocLink(tenant.documentUrl)}
+