diff --git a/README.md b/README.md
index fe95650..4895020 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Open it, enter your company name, and you get:
- **260+ standard PM tasks** across 17 departments, all editable to match your company
- **Team assignment engine** with auto-assign, workload balancing, and role templates
- **Maintenance work orders** tracked from intake to completion
-- **Property, tenant, and vendor registry**
+- **Property, tenant, and vendor registry** with rent/lease tracking, delinquency alerts, document links, and CSV bulk import
- **Exportable operations handbook** (Markdown + printable HTML)
- **Recurring task templates** for weekly ops, monthly finance, compliance, and more
- **Auto-backups** so nothing gets lost
@@ -194,6 +194,8 @@ Track properties, tenants, and vendors in the **Portfolio tab**. Tenant records
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.
+**Bulk import from CSV.** Already tracking properties, tenants, or vendors in a spreadsheet? Click **Import CSV** next to any of the three Portfolio forms to add them all at once instead of typing each one in — no need to change your existing sheet's column order, just the column names need to roughly match. Column headers match what **Export CSV** produces for that same record type, so exporting, editing in Excel/Sheets, and re-importing is a real round trip. Import always adds new records; it never edits or removes existing ones, and shows a summary of skipped/warned rows before anything is saved.
+
---
### Operations Handbook Export
@@ -216,9 +218,9 @@ Custom SVG diagram: Company → Departments → Owners. Line thickness scales wi
### Launch Plan — Guided First-Week Setup
-Appears automatically for new companies. Uses your portfolio size and operating focus (stability / maintenance / growth / compliance) to recommend the next best action, surface setup gaps, and walk through a structured first-week checklist.
+Appears automatically for new companies. Uses your portfolio size and operating focus (stability / maintenance / growth / compliance) to recommend the next best action, surface setup gaps, and walk through a structured first-week checklist. Departments start collapsed so a brand-new company sees a short list of headers, not all 260+ tasks expanded at once — click any department to open it, or **Expand All** in the filter bar to see everything.
-Includes a **Load Demo Company** option for exploring a realistic sample workspace before entering real data.
+Click **"Show me an example first"** right on the onboarding screen (or **Load Demo Company** later from the dashboard) to explore a realistic sample workspace before entering real data.
---
diff --git a/css/style.css b/css/style.css
index 045afbc..57a1c10 100644
--- a/css/style.css
+++ b/css/style.css
@@ -3357,6 +3357,13 @@ body {
margin-bottom: 14px;
}
+.portfolio-csv-btn {
+ flex-shrink: 0;
+ align-self: flex-start;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
.portfolio-panel h3,
.portfolio-list-head h3 {
color: var(--ink-strong, #17202a);
@@ -4260,6 +4267,17 @@ body[data-ops-focus="compliance"] {
background: var(--accent-strong);
}
+.setup-form .onboarding-btn--secondary {
+ background: transparent;
+ color: var(--accent-strong, #00695c);
+ border: 1.5px solid var(--line-strong, #c5d0d8);
+}
+
+.setup-form .onboarding-btn--secondary:hover {
+ background: var(--surface-muted, #f1f5f7);
+ border-color: var(--accent, #00897b);
+}
+
.nav-toggle-btn {
padding: 8px 12px;
border: 1px solid rgba(255,255,255,0.2);
diff --git a/index.html b/index.html
index 22078b8..26b62ac 100644
--- a/index.html
+++ b/index.html
@@ -72,6 +72,7 @@
PM Ops Map
+
@@ -258,6 +259,7 @@
Build your operations map.
+
diff --git a/js/__tests__/utils.test.js b/js/__tests__/utils.test.js
index e147d18..f1ef283 100644
--- a/js/__tests__/utils.test.js
+++ b/js/__tests__/utils.test.js
@@ -258,6 +258,72 @@ describe('isSafeUrl', () => {
});
});
+describe('parseCSV', () => {
+ test('parses simple unquoted rows', () => {
+ expect(utils.parseCSV('a,b,c\n1,2,3')).toEqual([['a', 'b', 'c'], ['1', '2', '3']]);
+ });
+
+ test('handles CRLF line endings', () => {
+ expect(utils.parseCSV('a,b\r\n1,2\r\n')).toEqual([['a', 'b'], ['1', '2']]);
+ });
+
+ test('does not produce a phantom row for a trailing newline', () => {
+ expect(utils.parseCSV('a,b\n1,2\n')).toEqual([['a', 'b'], ['1', '2']]);
+ });
+
+ test('handles quoted fields containing commas', () => {
+ expect(utils.parseCSV('name,notes\n"Oak St","Gate code, ask owner"')).toEqual([
+ ['name', 'notes'],
+ ['Oak St', 'Gate code, ask owner'],
+ ]);
+ });
+
+ test('handles escaped double quotes inside a quoted field', () => {
+ expect(utils.parseCSV('name\n"Bob ""The Landlord"" Smith"')).toEqual([
+ ['name'],
+ ['Bob "The Landlord" Smith'],
+ ]);
+ });
+
+ test('handles embedded newlines inside a quoted field', () => {
+ expect(utils.parseCSV('name,notes\nOak,"Line one\nLine two"')).toEqual([
+ ['name', 'notes'],
+ ['Oak', 'Line one\nLine two'],
+ ]);
+ });
+
+ test('handles empty fields', () => {
+ expect(utils.parseCSV('a,b,c\n1,,3')).toEqual([['a', 'b', 'c'], ['1', '', '3']]);
+ });
+
+ test('empty string returns no rows', () => {
+ expect(utils.parseCSV('')).toEqual([]);
+ });
+
+ test('single row with no trailing newline', () => {
+ expect(utils.parseCSV('a,b,c')).toEqual([['a', 'b', 'c']]);
+ });
+});
+
+describe('buildCsvHeaderMap', () => {
+ test('maps header names to indexes, case-insensitively and trimmed', () => {
+ expect(utils.buildCsvHeaderMap(['Property', ' Units ', 'Owner / Client'])).toEqual({
+ 'property': 0,
+ 'units': 1,
+ 'owner / client': 2,
+ });
+ });
+
+ test('skips blank header cells', () => {
+ expect(utils.buildCsvHeaderMap(['Name', '', 'Email'])).toEqual({ name: 0, email: 2 });
+ });
+
+ test('empty/undefined header row returns empty map', () => {
+ expect(utils.buildCsvHeaderMap([])).toEqual({});
+ expect(utils.buildCsvHeaderMap(undefined)).toEqual({});
+ });
+});
+
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 76ee225..c5d3d36 100644
--- a/js/app.js
+++ b/js/app.js
@@ -36,7 +36,7 @@ import {
requestNotificationPermission, initNotifications,
} from './ui.js';
import {
- renderTrackingView, toggleDepartment,
+ renderTrackingView, toggleDepartment, toggleExpandAllDepartments,
startTaskEdit, showOwnerPicker, closeOwnerPicker, setTaskOwner,
populateOwnerFilter, applyFilter, clearFilter, debounceApplyFilter,
cycleTaskStatus, cycleTaskPriority,
@@ -69,6 +69,7 @@ import {
deleteProperty, deleteTenant, deleteVendor, addStarterExample,
startEditProperty, startEditTenant, startEditVendor, cancelPortfolioEdit,
recordTenantPayment,
+ importPropertiesCSV, importTenantsCSV, importVendorsCSV,
} from './views/portfolio.js';
import {
exportJSON, exportCSV, exportPropertiesCSV, exportTenantsCSV,
@@ -81,7 +82,7 @@ import {
renderLaunchPlan, toggleLaunchChecklistItem, resetLaunchChecklist,
focusLaunchDepartment, downloadLaunchPlan,
startTeamSetup, showUnownedTasks, startWorkOrderSetup, downloadLaunchHandbook,
- focusCoverageArea, loadDemoCompany, printLaunchPlan,
+ focusCoverageArea, loadDemoCompany, startWithDemoCompany, printLaunchPlan,
} from './launchPlan.js';
import { downloadOperationsHandbook, downloadOperationsHandbookHTML, printOperationsHandbook } from './handbook.js';
import {
@@ -166,11 +167,11 @@ function initApp() {
applyNavCompactState();
initSync();
- // Start with all departments open so a first-time user can immediately see
- // the operational checklist instead of hunting through collapsed sections.
- document.querySelectorAll('.department').forEach(dept => {
- dept.classList.add('expanded');
- });
+ // Departments start collapsed — a brand-new company otherwise lands on a
+ // wall of 260+ UNOWNED tasks across 17 open departments, which is the
+ // opposite of the "start here" guidance the Launch Plan is trying to give.
+ // Collapsed headers still show task count and done/blocked totals, and
+ // "Expand All" in the filter bar is one click away.
// A saved company name means the user has already onboarded. Otherwise we
// show the lightweight setup prompt that personalizes exports and headings.
@@ -244,6 +245,7 @@ Object.assign(window, {
// dependencies, and filters.
renderTrackingView,
toggleDepartment,
+ toggleExpandAllDepartments,
startTaskEdit,
showOwnerPicker,
closeOwnerPicker,
@@ -314,6 +316,9 @@ Object.assign(window, {
startEditVendor,
cancelPortfolioEdit,
recordTenantPayment,
+ importPropertiesCSV,
+ importTenantsCSV,
+ importVendorsCSV,
// Import/export tools: let users keep ownership of their data with plain files
// or clipboard transfer instead of needing a hosted database.
exportJSON,
@@ -355,6 +360,7 @@ Object.assign(window, {
downloadLaunchPlan,
printLaunchPlan,
loadDemoCompany,
+ startWithDemoCompany,
startTeamSetup,
showUnownedTasks,
startWorkOrderSetup,
diff --git a/js/io.js b/js/io.js
index 2873ea2..19c8907 100644
--- a/js/io.js
+++ b/js/io.js
@@ -149,13 +149,14 @@ export function exportCSV() {
}
export function exportPropertiesCSV() {
- const rows = [['Property', 'Units', 'Owner / Client', 'Notes', 'Created At']];
+ const rows = [['Property', 'Units', 'Owner / Client', 'Notes', 'Document Link', 'Created At']];
portfolio.properties.forEach(property => {
rows.push([
property.name,
Number(property.units || 0),
property.owner || '',
property.notes || '',
+ property.documentUrl || '',
property.createdAt || '',
]);
});
@@ -163,7 +164,10 @@ export function exportPropertiesCSV() {
}
export function exportTenantsCSV() {
- const rows = [['Tenant', 'Property', 'Unit', 'Status', 'Phone', 'Email', 'Created At']];
+ const rows = [[
+ 'Tenant', 'Property', 'Unit', 'Status', 'Phone', 'Email',
+ 'Monthly Rent', 'Lease Start', 'Lease End', 'Balance Due', 'Document Link', 'Created At',
+ ]];
portfolio.tenants.forEach(tenant => {
rows.push([
tenant.name,
@@ -172,6 +176,11 @@ export function exportTenantsCSV() {
tenant.status || 'active',
tenant.phone || '',
tenant.email || '',
+ Number(tenant.rent || 0),
+ tenant.leaseStart || '',
+ tenant.leaseEnd || '',
+ Number(tenant.balanceDue || 0),
+ tenant.documentUrl || '',
tenant.createdAt || '',
]);
});
@@ -179,13 +188,14 @@ export function exportTenantsCSV() {
}
export function exportVendorsCSV() {
- const rows = [['Vendor', 'Trade', 'Phone', 'Email', 'Created At']];
+ const rows = [['Vendor', 'Trade', 'Phone', 'Email', 'Document Link', 'Created At']];
portfolio.vendors.forEach(vendor => {
rows.push([
vendor.name,
vendor.trade || '',
vendor.phone || '',
vendor.email || '',
+ vendor.documentUrl || '',
vendor.createdAt || '',
]);
});
diff --git a/js/launchPlan.js b/js/launchPlan.js
index 669162d..115aa01 100644
--- a/js/launchPlan.js
+++ b/js/launchPlan.js
@@ -474,8 +474,7 @@ export function downloadLaunchHandbook() {
if (window.downloadOperationsHandbook) window.downloadOperationsHandbook();
}
-export function loadDemoCompany() {
- if (!confirm('Load the demo company?\n\nThis replaces local portfolio, team, work orders, company profile, and task assignments with a realistic sample workspace.')) return;
+function _applyDemoCompanyData() {
const demo = buildDemoWorkspace();
const template = ROLE_TEMPLATES.maintenanceHeavy;
const employees = template.employees.map(emp => ({
@@ -505,9 +504,23 @@ export function loadDemoCompany() {
if (window.populateOwnerFilter) window.populateOwnerFilter();
if (window.renderLegend) window.renderLegend();
if (window.updateStats) window.updateStats();
+}
+
+export function loadDemoCompany() {
+ if (!confirm('Load the demo company?\n\nThis replaces local portfolio, team, work orders, company profile, and task assignments with a realistic sample workspace.')) return;
+ _applyDemoCompanyData();
_showActionToast('Demo company loaded', 'save-toast--success');
}
+// Called from the onboarding modal's "Show me an example first" button — no
+// confirmation dialog, since onboarding only ever runs on an empty workspace
+// with nothing to lose.
+export function startWithDemoCompany() {
+ document.getElementById('onboarding-modal')?.classList.remove('visible');
+ _applyDemoCompanyData();
+ _showActionToast('Demo company loaded — explore it, then replace with your own data anytime', 'save-toast--success');
+}
+
export function focusLaunchDepartment(deptId) {
const deptEl = document.querySelector(`.department[data-id="${deptId}"]`);
if (!deptEl) return;
diff --git a/js/utils.cjs b/js/utils.cjs
index 8ff3eef..f119202 100644
--- a/js/utils.cjs
+++ b/js/utils.cjs
@@ -111,6 +111,45 @@ function isSafeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url.trim());
}
+function parseCSV(text) {
+ const rows = [];
+ let row = [];
+ let field = '';
+ let inQuotes = false;
+ const str = String(text || '');
+ const len = str.length;
+
+ for (let i = 0; i < len; i++) {
+ const char = str[i];
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (str[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
+ } else {
+ field += char;
+ }
+ continue;
+ }
+
+ if (char === '"') { inQuotes = true; continue; }
+ if (char === ',') { row.push(field); field = ''; continue; }
+ if (char === '\r') { continue; }
+ if (char === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
+ field += char;
+ }
+ if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
+ return rows;
+}
+
+function buildCsvHeaderMap(headerRow) {
+ const map = {};
+ (headerRow || []).forEach((name, idx) => {
+ const key = String(name || '').trim().toLowerCase();
+ if (key) map[key] = idx;
+ });
+ return map;
+}
+
module.exports = {
escapeHtml,
jsonAttr,
@@ -128,4 +167,6 @@ module.exports = {
getLeaseStatus,
getDelinquencyStatus,
isSafeUrl,
+ parseCSV,
+ buildCsvHeaderMap,
};
diff --git a/js/utils.js b/js/utils.js
index a92b1bc..ca0fed6 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -132,6 +132,51 @@ function isSafeUrl(url) {
return typeof url === 'string' && /^https?:\/\//i.test(url.trim());
}
+// Minimal RFC4180-style CSV parser: handles quoted fields, embedded commas,
+// embedded newlines inside quotes, escaped "" quotes, and CRLF/LF endings.
+// Returns an array of row arrays; drops a single trailing blank line so a
+// file that ends with a newline doesn't produce a phantom empty row.
+function parseCSV(text) {
+ const rows = [];
+ let row = [];
+ let field = '';
+ let inQuotes = false;
+ const len = String(text || '').length;
+ const str = String(text || '');
+
+ for (let i = 0; i < len; i++) {
+ const char = str[i];
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (str[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
+ } else {
+ field += char;
+ }
+ continue;
+ }
+
+ if (char === '"') { inQuotes = true; continue; }
+ if (char === ',') { row.push(field); field = ''; continue; }
+ if (char === '\r') { continue; }
+ if (char === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
+ field += char;
+ }
+ if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
+ return rows;
+}
+
+// Builds a case-insensitive header-name -> column-index map, e.g. for
+// matching a CSV's own column order against expected field names.
+function buildCsvHeaderMap(headerRow) {
+ const map = {};
+ (headerRow || []).forEach((name, idx) => {
+ const key = String(name || '').trim().toLowerCase();
+ if (key) map[key] = idx;
+ });
+ return map;
+}
+
export {
escapeHtml,
jsonAttr,
@@ -149,4 +194,6 @@ export {
getLeaseStatus,
getDelinquencyStatus,
isSafeUrl,
+ parseCSV,
+ buildCsvHeaderMap,
};
diff --git a/js/views/portfolio.js b/js/views/portfolio.js
index dc128d9..ea9c5f2 100644
--- a/js/views/portfolio.js
+++ b/js/views/portfolio.js
@@ -1,6 +1,9 @@
import { portfolio, setPortfolio, workOrders } from '../state.js';
-import { savePortfolio, saveWorkOrders, logAudit, _showActionToast } from '../storage.js';
-import { escapeHtml, shakeInput, isValidISODate, formatCurrency, getLeaseStatus, getDelinquencyStatus, isSafeUrl } from '../utils.js';
+import { savePortfolio, saveWorkOrders, logAudit, _showActionToast, saveBackupSnapshot } from '../storage.js';
+import {
+ escapeHtml, shakeInput, isValidISODate, formatCurrency, getLeaseStatus, getDelinquencyStatus, isSafeUrl,
+ parseCSV, buildCsvHeaderMap,
+} from '../utils.js';
import { renderLaunchPlan } from '../launchPlan.js';
const editState = {
@@ -69,6 +72,12 @@ export function renderPortfolioView() {