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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

---

Expand Down
18 changes: 18 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ <h1>PM Ops Map</h1>
<option value="low">🟢 Low</option>
</select>
<button class="btn btn-secondary" onclick="clearFilter()">Clear</button>
<button class="btn btn-secondary" id="expand-all-btn" onclick="toggleExpandAllDepartments()" title="Expand or collapse every department at once">&#9660; Expand All</button>
<button class="btn btn-secondary" id="bulk-toggle-btn" onclick="toggleBulkMode()" title="Select multiple tasks to reassign or change status in bulk">&#9745; Bulk Select</button>
<!-- ★ BEACON: Auto-assign button. The badge inside shows the live UNOWNED count.
JS keeps this disabled when count = 0 so managers always know the state. -->
Expand Down Expand Up @@ -258,6 +259,7 @@ <h2 id="onboarding-title" class="onboarding-title">Build your operations map.</h
</select>

<button class="onboarding-btn" onclick="submitCompanyName()">Start Mapping &rarr;</button>
<button class="onboarding-btn onboarding-btn--secondary" onclick="startWithDemoCompany()">Show me an example first</button>
</div>
</div>
</div>
Expand Down
66 changes: 66 additions & 0 deletions js/__tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
20 changes: 13 additions & 7 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -244,6 +245,7 @@ Object.assign(window, {
// dependencies, and filters.
renderTrackingView,
toggleDepartment,
toggleExpandAllDepartments,
startTaskEdit,
showOwnerPicker,
closeOwnerPicker,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -355,6 +360,7 @@ Object.assign(window, {
downloadLaunchPlan,
printLaunchPlan,
loadDemoCompany,
startWithDemoCompany,
startTeamSetup,
showUnownedTasks,
startWorkOrderSetup,
Expand Down
16 changes: 13 additions & 3 deletions js/io.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,21 +149,25 @@ 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 || '',
]);
});
downloadCSV(rows, `pm-ops-${_fileSlug()}-properties.csv`);
}

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,
Expand All @@ -172,20 +176,26 @@ 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 || '',
]);
});
downloadCSV(rows, `pm-ops-${_fileSlug()}-tenants.csv`);
}

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 || '',
]);
});
Expand Down
17 changes: 15 additions & 2 deletions js/launchPlan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions js/utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -128,4 +167,6 @@ module.exports = {
getLeaseStatus,
getDelinquencyStatus,
isSafeUrl,
parseCSV,
buildCsvHeaderMap,
};
Loading
Loading