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)}
+