Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

---

Expand Down
15 changes: 15 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions js/__tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>')).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' };
Expand Down
2 changes: 2 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion js/launchPlan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -841,6 +843,8 @@ function getOperationsSnapshot() {
employeeCount: teamData.employees.length,
isStarterTeam: isStarterTeam(),
leasesNeedingAttention,
delinquentTenants,
totalBalanceDue,
};
}

Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions js/templates.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }],
};
Expand Down
4 changes: 4 additions & 0 deletions js/templates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
],
Expand All @@ -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(),
},
],
Expand All @@ -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(),
},
],
Expand Down
14 changes: 14 additions & 0 deletions js/utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -114,4 +126,6 @@ module.exports = {
formatWODate,
formatCurrency,
getLeaseStatus,
getDelinquencyStatus,
isSafeUrl,
};
20 changes: 20 additions & 0 deletions js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href>, 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,
Expand All @@ -129,4 +147,6 @@ export {
formatWODate,
formatCurrency,
getLeaseStatus,
getDelinquencyStatus,
isSafeUrl,
};
Loading
Loading