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() {

${editingProperty ? 'Edit Property' : 'Add Property'}

${editingProperty ? 'Update owner/client context, units, or operating notes.' : 'Track the minimum context needed to run early operations.'}

+ ${!editingProperty ? ` + + ` : ''}
+ ${!editingTenant ? ` + + ` : ''}
+ ${!editingVendor ? ` + + ` : ''}