Spaces:
Running
Running
Download rentsprig/app.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 48.6 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/rentsprig/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/rentsprig/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/rentsprig/app.js
48.6 kB
| /* Rentsprig app: properties and tenants, rent receipts, rent ledger, reminder letter, move-in/move-out checklists with photos, | |
| * live preview, PDF/print, Pro features. Everything stays in this browser. © 2026 CyberMax. All rights reserved. */ | |
| (function () { | |
| "use strict"; | |
| var R = window.Rent, P = window.RentPDF, X = window.CMXPro, PH = window.RentPhotos; | |
| var KEY = "rentsprig:v1"; | |
| var HUB = "https://cybermax-tools-cybermax.static.hf.space/rentsprig/index.html?utm_source=pdf-credit"; | |
| var CREDIT = "Made with Rentsprig by CyberMax · free rent receipt generator"; | |
| var $ = function (id) { return document.getElementById(id); }; | |
| var qa = function (sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); }; | |
| function pro() { return X.isPro(); } | |
| function toast(m) { X.toast(m); } | |
| function upsell(msg, from) { X.upsell(msg || "That is a Rentsprig Pro feature.", from); } | |
| function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } | |
| /* ---------- state ---------- */ | |
| function firstOfMonth() { return R.todayISO().slice(0, 8) + "01"; } | |
| function addDays(iso, n) { var p = iso.split("-"), d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2] + n)); return d.toISOString().slice(0, 10); } | |
| function blankReceipt() { | |
| return { no: "", date: R.todayISO(), tenantId: "", from: "", address: "", periodFrom: firstOfMonth(), periodTo: R.monthEnd(firstOfMonth()), amount: "", method: "Bank transfer", | |
| methodOther: "", methodRef: "", by: (S && S.landlord && S.landlord.name) || "", balance: "0", note: "", signature: "", post: true }; | |
| } | |
| function blankLetter() { | |
| return { tenantId: "", tenant: "", property: "", date: R.todayISO(), amount: "", dueDate: firstOfMonth(), payBy: addDays(R.todayISO(), 7), lateFee: "", howToPay: "", contact: "", landlord: "", | |
| subject: R.LETTER_SUBJECT, body: R.LETTER_BODY }; | |
| } | |
| function fresh() { | |
| return { landlord: { name: "", address: "", phone: "", email: "" }, currency: guessCurrency(), page: "", logo: null, properties: [], tenants: [], receipt: null, receipts: [], counter: 0, | |
| ledgers: {}, ledgerTenant: "", letter: null, checklists: [], clId: "", cmp: { in: "", out: "" }, tab: "receipt", results: 0 }; | |
| } | |
| function guessCurrency() { | |
| try { | |
| var loc = navigator.language || "en-US", tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; | |
| if (/^en-GB/.test(loc) || /London/.test(tz)) return "GBP"; | |
| if (/^en-CA|^fr-CA/.test(loc)) return "CAD"; | |
| if (/^en-AU/.test(loc) || /^Australia/.test(tz)) return "AUD"; | |
| if (/^en-NZ/.test(loc)) return "NZD"; | |
| if (/^en-IN/.test(loc) || /Kolkata|Calcutta/.test(tz)) return "INR"; | |
| if (/Johannesburg/.test(tz)) return "ZAR"; | |
| if (/^Europe\//.test(tz)) return "EUR"; | |
| } catch (e) { /* default */ } | |
| return "USD"; | |
| } | |
| var S = null; | |
| function load() { | |
| var s = null; | |
| try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; } | |
| var f = fresh(); | |
| if (!s || typeof s !== "object") s = f; | |
| Object.keys(f).forEach(function (k) { if (s[k] == null) s[k] = f[k]; }); | |
| return s; | |
| } | |
| var firstVisit = false; | |
| try { firstVisit = !localStorage.getItem(KEY); } catch (e) { firstVisit = true; } | |
| S = load(); | |
| if (firstVisit) { | |
| // First visit: show a finished receipt and letter straight away (example data you overwrite as you type). | |
| var ex0 = R.example(); | |
| S.landlord = ex0.landlord; S.currency = "USD"; S.receipt = ex0.receipt; S.receipt.no = ""; S.receipt.tenantId = ""; S.letter = ex0.letter; S.letter.tenantId = ""; | |
| } | |
| if (!S.receipt) S.receipt = blankReceipt(); | |
| if (!S.letter) S.letter = blankLetter(); | |
| var saveT = null; | |
| function save() { | |
| clearTimeout(saveT); | |
| saveT = setTimeout(saveNow, 150); | |
| } | |
| function saveNow() { | |
| clearTimeout(saveT); | |
| try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save in this browser (storage full or private mode)."); } | |
| } | |
| function ctx() { return { landlord: S.landlord, currency: S.currency }; } | |
| function page() { return S.page || R.cur(S.currency).page; } | |
| function tenant(id) { return S.tenants.filter(function (t) { return t.id === id; })[0] || null; } | |
| function prop(id) { return S.properties.filter(function (p) { return p.id === id; })[0] || null; } | |
| function checklist() { return S.checklists.filter(function (c) { return c.id === S.clId; })[0] || S.checklists[0] || null; } | |
| var cmpMode = false; | |
| /* ---------- tabs ---------- */ | |
| var docTab = S.tab === "setup" ? "receipt" : S.tab; | |
| function switchTab(t, focus) { | |
| S.tab = t; if (t !== "setup") docTab = t; | |
| qa(".tabs [role=tab]").forEach(function (b) { var on = b.dataset.tab === t; b.setAttribute("aria-selected", String(on)); b.tabIndex = on ? 0 : -1; if (on && focus) b.focus(); }); | |
| qa(".panel").forEach(function (p) { p.hidden = p.id !== "panel-" + t; }); | |
| if (t === "checklist") ensureChecklist(); | |
| save(); renderPanel(); renderPreview(); | |
| } | |
| qa(".tabs [role=tab]").forEach(function (b, i, all) { | |
| b.addEventListener("click", function () { switchTab(b.dataset.tab); }); | |
| b.addEventListener("keydown", function (e) { | |
| var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; | |
| if (d) { e.preventDefault(); switchTab(all[(i + d + all.length) % all.length].dataset.tab, true); } | |
| }); | |
| }); | |
| /* ---------- forms <-> model ---------- */ | |
| function model(form) { | |
| var k = form.dataset.form; | |
| if (k === "receipt") return S.receipt; | |
| if (k === "letter") return S.letter; | |
| if (k === "checklist") return checklist(); | |
| if (k === "landlord") return S.landlord; | |
| return null; | |
| } | |
| function toForm(form) { | |
| var m = model(form); if (!m) return; | |
| qa("[name]", form).forEach(function (el) { | |
| var v = form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page") ? S[el.name] : m[el.name]; | |
| if (el.type === "checkbox") el.checked = !!v && (el.name !== "post" || pro()); | |
| else el.value = v == null ? "" : v; | |
| }); | |
| } | |
| function allForms() { qa("form[data-form]").forEach(toForm); syncReceiptUi(); } | |
| qa("form[data-form]").forEach(function (form) { | |
| form.addEventListener("submit", function (e) { e.preventDefault(); }); | |
| form.addEventListener("input", function (e) { | |
| var el = e.target, m = model(form); | |
| if (!el.name || !m) return; | |
| if (form.dataset.form === "landlord" && (el.name === "currency" || el.name === "page")) S[el.name] = el.value; | |
| else if (el.type === "checkbox") { | |
| if (el.name === "post" && !pro()) { el.checked = false; return upsell("Posting receipts to the rent ledger is part of Pro.", "ledger-post"); } | |
| m[el.name] = el.checked; | |
| } else m[el.name] = el.value; | |
| if (form.dataset.form === "receipt") syncReceiptUi(); | |
| if (form.dataset.form === "checklist" && el.name === "date") renderPicker(); | |
| changed(); | |
| }); | |
| }); | |
| function syncReceiptUi() { | |
| qa(".r-other").forEach(function (el) { el.hidden = S.receipt.method !== "Other"; }); | |
| $("r-no-hint").textContent = pro() ? "numbered for you" : "type your own"; | |
| } | |
| /* tenant pickers */ | |
| function tenantLabel(t) { var p = prop(t.propertyId); return t.name + (t.unit ? " · " + t.unit : "") + (p ? " · " + p.name : ""); } | |
| function renderTenantSelects() { | |
| qa(".tenant-select").forEach(function (sel) { | |
| var kind = sel.dataset.fill, cur = kind === "ledger" ? S.ledgerTenant : kind === "receipt" ? S.receipt.tenantId : kind === "letter" ? S.letter.tenantId : (checklist() || {}).tenantId; | |
| sel.innerHTML = (kind === "ledger" ? "" : '<option value="">' + (S.tenants.length ? "Choose a tenant…" : "No tenants yet (add them under Properties & tenants)") + "</option>") + | |
| S.tenants.map(function (t) { return '<option value="' + esc(t.id) + '">' + esc(tenantLabel(t)) + "</option>"; }).join(""); | |
| if (kind === "ledger" && !S.tenants.length) sel.innerHTML = '<option value="">No tenants yet</option>'; | |
| sel.value = cur && tenant(cur) ? cur : (kind === "ledger" && S.tenants[0] ? S.tenants[0].id : ""); | |
| if (kind === "ledger") S.ledgerTenant = sel.value; | |
| }); | |
| } | |
| qa(".tenant-select").forEach(function (sel) { | |
| sel.addEventListener("change", function () { | |
| var t = tenant(sel.value), kind = sel.dataset.fill; | |
| if (kind === "ledger") { S.ledgerTenant = sel.value; renderLedger(); return changed(); } | |
| if (!t) return; | |
| var p = prop(t.propertyId), addr = R.addressOf(p, t); | |
| if (kind === "receipt") { | |
| var r = S.receipt, from = R.isISO(r.date) ? r.date.slice(0, 8) + "01" : firstOfMonth(); | |
| r.tenantId = t.id; r.from = t.name; r.address = addr; r.amount = t.rent || r.amount; r.periodFrom = from; r.periodTo = R.monthEnd(from); | |
| if (!r.by) r.by = S.landlord.name || ""; | |
| toForm($("panel-receipt")); | |
| } else if (kind === "letter") { | |
| var l = S.letter, bal = R.ledger(S.ledgers[t.id] || []).balance; | |
| l.tenantId = t.id; l.tenant = t.name; l.property = addr; l.amount = pro() && bal > 0 ? String(bal) : t.rent || l.amount; l.dueDate = R.dueDate(R.todayISO(), t.dueDay); | |
| if (!l.landlord) l.landlord = S.landlord.name || ""; if (!l.contact) l.contact = [S.landlord.phone, S.landlord.email].filter(Boolean).join(" or "); | |
| toForm($("panel-letter")); | |
| } else if (kind === "checklist") { | |
| var c = checklist(); if (!c) return; | |
| c.tenantId = t.id; c.tenant = t.name; c.property = [p && p.name, t.unit].filter(Boolean).join(", "); | |
| if (!c.by) c.by = S.landlord.name || ""; | |
| toForm($("c-form")); renderPicker(); | |
| } | |
| changed(); | |
| }); | |
| }); | |
| /* ---------- receipts ---------- */ | |
| function noteNumber(no) { var v = R.receiptValue(no); if (v != null && v > (S.counter || 0)) S.counter = v; } | |
| function renderReceiptHistory() { | |
| var body = $("r-hist-body"); | |
| $("btn-receipts-csv").hidden = !pro(); | |
| if (!pro()) { | |
| body.innerHTML = '<div class="locked"><p>Pro numbers your receipts (R-0001, R-0002…), keeps every one you make, reopens any of them and exports the list as a CSV.</p><button type="button" class="btn sm" data-up="history">Unlock history</button></div>'; | |
| return; | |
| } | |
| if (!S.receipts.length) { body.innerHTML = '<p class="hint">Receipts you download or print appear here.</p>'; return; } | |
| body.innerHTML = '<div class="table-wrap"><table class="ht"><thead><tr><th>No.</th><th>Date</th><th>From</th><th class="num">Amount</th><th></th></tr></thead><tbody>' + | |
| S.receipts.map(function (r, i) { | |
| return '<tr><td>' + esc(r.no) + "</td><td>" + esc(R.fmtDate(r.date, r.currency)) + "</td><td>" + esc(r.from) + '</td><td class="num">' + esc(R.money(R.parseNum(r.amount), r.currency)) + | |
| '</td><td><button type="button" class="btn ghost sm" data-rh="open" data-i="' + i + '">Open</button> <button type="button" class="icon-btn" data-rh="del" data-i="' + i + '" aria-label="Delete receipt ' + esc(r.no) + '">×</button></td></tr>'; | |
| }).join("") + "</tbody></table></div>"; | |
| } | |
| $("r-hist-body").addEventListener("click", function (e) { | |
| var b = e.target.closest("[data-rh]"); if (!b) return; | |
| var i = +b.dataset.i, r = S.receipts[i]; if (!r) return; | |
| if (b.dataset.rh === "open") { S.receipt = R.clone(r); toForm($("panel-receipt")); syncReceiptUi(); changed(); $("app").scrollIntoView({ behavior: "smooth" }); } | |
| else if (confirm("Delete receipt " + r.no + " from history?")) { S.receipts.splice(i, 1); save(); renderReceiptHistory(); } | |
| }); | |
| $("btn-receipts-csv").addEventListener("click", function () { | |
| if (!pro()) return upsell(null, "receipts-csv"); | |
| if (!S.receipts.length) return toast("No receipts in history yet."); | |
| X.download("rentsprig-receipts-" + R.todayISO() + ".csv", R.receiptsCsv(S.receipts), "text/csv;charset=utf-8"); | |
| }); | |
| $("btn-new-receipt").addEventListener("click", function () { | |
| var keep = S.receipt; | |
| S.receipt = blankReceipt(); | |
| S.receipt.signature = keep.signature; S.receipt.method = keep.method; | |
| if (pro()) S.receipt.no = R.receiptNo((S.counter || 0) + 1); | |
| toForm($("panel-receipt")); syncReceiptUi(); renderTenantSelects(); changed(); | |
| toast(pro() ? "New receipt " + S.receipt.no + "." : "New receipt. Pick a tenant to fill it in."); | |
| }); | |
| function recordReceipt() { | |
| var r = S.receipt; | |
| if (!r.no) r.no = R.receiptNo((S.counter || 0) + 1); | |
| noteNumber(r.no); | |
| r.currency = S.currency; | |
| S.receipts = [R.clone(r)].concat(S.receipts.filter(function (x) { return x.no !== r.no; })).slice(0, 2000); | |
| if (r.post && r.tenantId && R.parseNum(r.amount) > 0) { | |
| var list = S.ledgers[r.tenantId] = S.ledgers[r.tenantId] || [], id = "rc-" + r.no; | |
| var entry = { id: id, date: r.date, type: "payment", desc: "Receipt " + r.no + " · " + R.methodText(r), amount: String(R.parseNum(r.amount)) }; | |
| var at = list.map(function (e) { return e.id; }).indexOf(id); | |
| if (at > -1) list[at] = entry; else list.push(entry); | |
| } | |
| toForm($("panel-receipt")); | |
| renderReceiptHistory(); | |
| } | |
| /* ---------- ledger ---------- */ | |
| function renderLedger() { | |
| var tid = S.ledgerTenant, t = tenant(tid), locked = !pro(); | |
| $("l-locked").hidden = !locked; | |
| $("panel-ledger").classList.toggle("is-locked", locked); | |
| $("l-btns").hidden = locked || !t; | |
| if (!t) { $("l-rows").innerHTML = '<p class="hint">Add a tenant under Properties & tenants, or press Load example.</p>'; return; } | |
| var list = S.ledgers[tid] || [], L = R.ledger(list), bal = {}; | |
| L.rows.forEach(function (r) { bal[r.id] = r.balance; }); | |
| var dis = locked ? " disabled" : ""; | |
| $("l-rows").innerHTML = list.map(function (e, i) { | |
| var id = "le" + i; | |
| return '<div class="lrow" data-i="' + i + '">' + | |
| '<div><label for="' + id + '-d">Date</label><input id="' + id + '-d" type="date" data-k="date" value="' + esc(e.date) + '"' + dis + "></div>" + | |
| '<div><label for="' + id + '-t">Type</label><select id="' + id + '-t" data-k="type"' + dis + ">" + Object.keys(R.ENTRY_TYPES).map(function (k) { return '<option value="' + k + '"' + (e.type === k ? " selected" : "") + ">" + R.ENTRY_TYPES[k].label + "</option>"; }).join("") + "</select></div>" + | |
| '<div class="l-desc"><label for="' + id + '-x">Description</label><input id="' + id + '-x" data-k="desc" value="' + esc(e.desc) + '"' + dis + "></div>" + | |
| '<div><label for="' + id + '-a">Amount</label><input id="' + id + '-a" data-k="amount" inputmode="decimal" value="' + esc(e.amount) + '"' + dis + "></div>" + | |
| '<div class="l-bal">' + (e.id in bal ? "Balance " + esc(R.money(bal[e.id], S.currency)) : "") + "</div>" + | |
| (locked ? "" : '<div class="l-tools span2"><button type="button" class="icon-btn" data-del="' + i + '" aria-label="Remove entry ' + (i + 1) + '">×</button></div>') + "</div>"; | |
| }).join("") || '<p class="hint">No entries yet. Add this month\'s rent to start.</p>'; | |
| } | |
| $("l-rows").addEventListener("input", function (e) { | |
| var row = e.target.closest(".lrow"); if (!row || !pro()) return; | |
| var t = S.ledgerTenant, en = (S.ledgers[t] || [])[+row.dataset.i]; if (!en) return; | |
| en[e.target.dataset.k] = e.target.value; | |
| changed(); | |
| var bal = {}; R.ledger(S.ledgers[t]).rows.forEach(function (r) { bal[r.id] = r.balance; }); | |
| qa(".lrow", $("l-rows")).forEach(function (r) { var x = S.ledgers[t][+r.dataset.i]; r.querySelector(".l-bal").textContent = x && x.id in bal ? "Balance " + R.money(bal[x.id], S.currency) : ""; }); | |
| }); | |
| $("l-rows").addEventListener("click", function (e) { | |
| var b = e.target.closest("[data-del]"); if (!b || !pro()) return; | |
| (S.ledgers[S.ledgerTenant] || []).splice(+b.dataset.del, 1); renderLedger(); changed(); | |
| }); | |
| function addEntry(type) { | |
| if (!pro()) return upsell("The rent ledger is part of Pro.", "ledger"); | |
| var t = tenant(S.ledgerTenant); if (!t) return toast("Add a tenant first."); | |
| var list = S.ledgers[t.id] = S.ledgers[t.id] || [], today = R.todayISO(); | |
| var e = { id: R.uid("e"), date: today, type: type, desc: "", amount: "" }; | |
| if (type === "rent") { | |
| e.date = R.dueDate(today, t.dueDay); e.amount = t.rent || ""; | |
| try { e.desc = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(e.date + "T00:00:00Z")) + " rent"; } catch (x) { e.desc = "Rent"; } | |
| } else if (type === "payment") { e.desc = "Payment"; e.amount = String(Math.max(0, R.ledger(list).balance) || ""); } | |
| else if (type === "late") e.desc = "Late fee per lease"; | |
| list.push(e); renderLedger(); changed(); | |
| var ins = qa("#l-rows input[data-k=amount]"); if (ins.length) ins[ins.length - 1].focus(); | |
| } | |
| $("btn-l-rent").addEventListener("click", function () { addEntry("rent"); }); | |
| $("btn-l-pay").addEventListener("click", function () { addEntry("payment"); }); | |
| $("btn-l-late").addEventListener("click", function () { addEntry("late"); }); | |
| $("btn-l-row").addEventListener("click", function () { addEntry("charge"); }); | |
| $("btn-ledger-csv").addEventListener("click", function () { | |
| if (!pro()) return upsell("CSV export of the rent ledger is part of Pro.", "ledger-csv"); | |
| var t = tenant(S.ledgerTenant); if (!t) return toast("Pick a tenant first."); | |
| X.download("rent-ledger-" + R.fileName({ title: t.name }, "csv"), R.ledgerCsv(R.ledger(S.ledgers[t.id] || []), t), "text/csv;charset=utf-8"); | |
| result(null); | |
| }); | |
| $("btn-ledger-pro").addEventListener("click", function () { upsell(null, "ledger"); }); | |
| /* ---------- letter ---------- */ | |
| $("btn-letter-reset").addEventListener("click", function () { | |
| S.letter.subject = R.LETTER_SUBJECT; S.letter.body = R.LETTER_BODY; toForm($("panel-letter")); changed(); toast("Letter text reset to the template."); | |
| }); | |
| /* ---------- checklists ---------- */ | |
| function ensureChecklist() { | |
| if (!S.checklists.length) { var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.push(c); S.clId = c.id; save(); } | |
| if (!checklist() || S.clId !== checklist().id) S.clId = checklist().id; | |
| } | |
| function clLabel(c) { return (c.kind === "out" ? "Move-out" : "Move-in") + " · " + (c.tenant || c.property || "untitled") + " · " + R.fmtDate(c.date, S.currency); } | |
| function renderPicker() { | |
| var c = checklist(); | |
| $("c-pick").innerHTML = S.checklists.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join(""); | |
| if (c) $("c-pick").value = c.id; | |
| var ins = S.checklists.filter(function (x) { return x.kind === "in"; }), outs = S.checklists.filter(function (x) { return x.kind === "out"; }); | |
| $("c-cin").innerHTML = ins.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join("") || '<option value="">No move-in checklist</option>'; | |
| $("c-cout").innerHTML = outs.map(function (x) { return '<option value="' + esc(x.id) + '">' + esc(clLabel(x)) + "</option>"; }).join("") || '<option value="">No move-out checklist yet</option>'; | |
| if (S.cmp.in && ins.some(function (x) { return x.id === S.cmp.in; })) $("c-cin").value = S.cmp.in; | |
| if (S.cmp.out && outs.some(function (x) { return x.id === S.cmp.out; })) $("c-cout").value = S.cmp.out; | |
| var used = S.checklists.length; | |
| $("c-quota").textContent = pro() ? "Pro: unlimited checklists, photos and comparison reports." : "Free: " + Math.min(used, 1) + " of 1 move-in checklist used, without photos. Pro adds more checklists, photos and the move-out comparison."; | |
| $("c-quota").className = "quota" + (!pro() && used >= 1 ? " out" : ""); | |
| } | |
| $("c-pick").addEventListener("change", function () { S.clId = this.value; cmpMode = false; toForm($("c-form")); renderTenantSelects(); renderRooms(); changed(); }); | |
| $("c-cin").addEventListener("change", function () { S.cmp.in = this.value; if (cmpMode) changed(); }); | |
| $("c-cout").addEventListener("change", function () { S.cmp.out = this.value; if (cmpMode) changed(); }); | |
| $("btn-c-newin").addEventListener("click", function () { | |
| if (!pro() && S.checklists.length >= R.FREE.checklists) return upsell("The free plan includes 1 move-in checklist. Pro adds unlimited checklists, photos and the move-out comparison.", "checklist-limit"); | |
| var c = R.newChecklist("in", { by: S.landlord.name }); S.checklists.unshift(c); S.clId = c.id; cmpMode = false; | |
| toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("New move-in checklist."); | |
| }); | |
| $("btn-c-newout").addEventListener("click", function () { | |
| if (!pro()) return upsell("Move-out checklists and the comparison report are part of Pro.", "move-out"); | |
| var src = checklist(); if (!src) return; | |
| var base = src.kind === "in" ? src : S.checklists.filter(function (x) { return x.kind === "in" && x.tenantId === src.tenantId; })[0] || src; | |
| var c = R.newChecklist("out", { tenantId: base.tenantId, tenant: base.tenant, property: base.property, by: base.by || S.landlord.name, rooms: base.rooms }); | |
| S.checklists.unshift(c); S.clId = c.id; S.cmp = { in: base.kind === "in" ? base.id : S.cmp.in, out: c.id }; cmpMode = false; | |
| toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); toast("Move-out checklist made from the move-in rooms. Record each item's condition now."); | |
| }); | |
| $("btn-c-del").addEventListener("click", function () { | |
| var c = checklist(); if (!c || !confirm("Delete this checklist and its photos?")) return; | |
| c.rooms.forEach(function (r) { r.items.forEach(function (it) { (it.photos || []).forEach(function (k) { PH.del(k); }); }); }); | |
| S.checklists = S.checklists.filter(function (x) { return x.id !== c.id; }); | |
| S.clId = S.checklists[0] ? S.checklists[0].id : ""; cmpMode = false; | |
| ensureChecklist(); toForm($("c-form")); renderTenantSelects(); renderPicker(); renderRooms(); changed(); | |
| }); | |
| $("btn-c-compare").addEventListener("click", function () { | |
| if (!pro()) return upsell("The move-out comparison report is part of Pro.", "compare"); | |
| S.cmp.in = $("c-cin").value; S.cmp.out = $("c-cout").value; | |
| if (!S.cmp.in || !S.cmp.out) return toast("You need a move-in and a move-out checklist to compare."); | |
| cmpMode = true; $("btn-c-back").hidden = false; renderPreview(); toast("Comparison ready. Download it as a PDF."); | |
| if (window.innerWidth < 980) $("preview").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| $("btn-c-back").addEventListener("click", function () { cmpMode = false; this.hidden = true; renderPreview(); }); | |
| function renderRooms() { | |
| var c = checklist(), box = $("c-rooms"); | |
| if (!c) { box.innerHTML = ""; return; } | |
| box.innerHTML = c.rooms.map(function (r, ri) { | |
| var done = r.items.filter(function (it) { return it.cond; }).length; | |
| return '<details class="card room" open data-r="' + ri + '"><summary>' + esc(r.name) + '<span class="rc">' + done + " of " + r.items.length + "</span></summary>" + | |
| r.items.map(function (it, ii) { | |
| var id = "ci-" + ri + "-" + ii; | |
| return '<div class="ci" data-i="' + ii + '"><div class="ci-top"><div class="ci-name"><label class="sr" for="' + id + '-n">Item name</label><input id="' + id + '-n" data-k="name" value="' + esc(it.name) + '"></div>' + | |
| '<div class="conds" role="group" aria-label="Condition of ' + esc(it.name) + '">' + R.CONDITIONS.map(function (k) { return '<button type="button" data-c="' + k + '" aria-pressed="' + (it.cond === k) + '">' + k + "</button>"; }).join("") + "</div>" + | |
| '<label class="btn ghost sm file-btn photo-btn" for="' + id + '-f" data-photo>+ Photo</label><input type="file" id="' + id + '-f" accept="image/*" multiple hidden data-file>' + | |
| '<button type="button" class="icon-btn" data-act="del-item" aria-label="Remove ' + esc(it.name) + '">×</button></div>' + | |
| '<div class="ci-notes"><label class="sr" for="' + id + '-x">Notes for ' + esc(it.name) + '</label><input id="' + id + '-x" data-k="notes" value="' + esc(it.notes) + '" placeholder="Notes (marks, stains, what works)"></div>' + | |
| ((it.photos || []).length ? '<div class="thumbs">' + it.photos.map(function (k, pi) { var p = PH.get(k); return '<div class="thumb">' + (p ? '<img src="' + p.data + '" alt="Photo ' + (pi + 1) + " of " + esc(it.name) + '">' : "") + '<button type="button" data-act="del-photo" data-p="' + pi + '" aria-label="Remove photo">×</button></div>'; }).join("") + "</div>" : "") + | |
| "</div>"; | |
| }).join("") + | |
| '<div class="room-tools"><label class="sr" for="rn-' + ri + '">Room name</label><input id="rn-' + ri + '" data-k="room" value="' + esc(r.name) + '"><button type="button" class="btn ghost sm" data-act="add-item">+ Item</button><button type="button" class="btn ghost sm danger" data-act="del-room">Remove room</button></div></details>'; | |
| }).join(""); | |
| } | |
| function where(el) { | |
| var rEl = el.closest("[data-r]"), iEl = el.closest(".ci"), c = checklist(); | |
| var r = rEl && c ? c.rooms[+rEl.dataset.r] : null; | |
| return { c: c, r: r, ri: rEl ? +rEl.dataset.r : -1, it: r && iEl ? r.items[+iEl.dataset.i] : null }; | |
| } | |
| $("c-rooms").addEventListener("input", function (e) { | |
| var w = where(e.target), k = e.target.dataset.k; if (!w.r) return; | |
| if (k === "room") { w.r.name = e.target.value; e.target.closest("details").querySelector("summary").firstChild.textContent = e.target.value; } | |
| else if (w.it && (k === "name" || k === "notes")) w.it[k] = e.target.value; | |
| changed(); | |
| }); | |
| $("c-rooms").addEventListener("click", function (e) { | |
| var b = e.target.closest("button,[data-photo]"); if (!b) return; | |
| var w = where(b); if (!w.r) return; | |
| if (b.hasAttribute("data-photo")) { | |
| if (!pro()) { e.preventDefault(); return upsell("Photos in checklists are part of Pro.", "photos"); } | |
| return; | |
| } | |
| if (b.dataset.c) { w.it.cond = w.it.cond === b.dataset.c ? "" : b.dataset.c; qa("button", b.parentNode).forEach(function (x) { x.setAttribute("aria-pressed", String(x.dataset.c === w.it.cond)); }); updateCount(b, w.r); return changed(); } | |
| var act = b.dataset.act; | |
| if (act === "del-item") { (w.it.photos || []).forEach(function (k) { PH.del(k); }); w.r.items.splice(w.r.items.indexOf(w.it), 1); } | |
| if (act === "del-photo") { var k = w.it.photos.splice(+b.dataset.p, 1)[0]; PH.del(k); } | |
| if (act === "add-item") { w.r.items.push({ name: "New item", cond: "", notes: "", photos: [] }); } | |
| if (act === "del-room") { if (!confirm("Remove " + w.r.name + " from this checklist?")) return; w.r.items.forEach(function (it) { (it.photos || []).forEach(function (k) { PH.del(k); }); }); w.c.rooms.splice(w.ri, 1); } | |
| renderRooms(); changed(); | |
| if (act === "add-item") { var ins = qa('[data-r="' + w.ri + '"] input[data-k=name]'); if (ins.length) { ins[ins.length - 1].focus(); ins[ins.length - 1].select(); } } | |
| }); | |
| function updateCount(el, r) { var s = el.closest("details").querySelector(".rc"); s.textContent = r.items.filter(function (it) { return it.cond; }).length + " of " + r.items.length; } | |
| $("c-rooms").addEventListener("change", function (e) { | |
| if (!e.target.hasAttribute("data-file")) return; | |
| var w = where(e.target), files = Array.prototype.slice.call(e.target.files || []); e.target.value = ""; | |
| if (!w.it || !files.length) return; | |
| if (!pro()) return upsell("Photos in checklists are part of Pro.", "photos"); | |
| Promise.all(files.map(shrink)).then(function (list) { | |
| list.forEach(function (p) { if (!p) return; var k = R.uid("ph"); PH.put(k, p); w.it.photos = w.it.photos || []; w.it.photos.push(k); }); | |
| saveNow(); renderRooms(); changed(); | |
| toast(list.filter(Boolean).length + " photo" + (list.length > 1 ? "s" : "") + " added to " + w.it.name + "."); | |
| }); | |
| }); | |
| $("btn-c-room").addEventListener("click", function () { | |
| var c = checklist(); if (!c) return; | |
| c.rooms.push({ name: "Room " + (c.rooms.length + 1), items: [{ name: "Walls & ceiling", cond: "", notes: "", photos: [] }, { name: "Floor", cond: "", notes: "", photos: [] }] }); | |
| renderRooms(); changed(); | |
| var ins = qa("#c-rooms input[data-k=room]"); ins[ins.length - 1].focus(); ins[ins.length - 1].select(); | |
| }); | |
| /** Shrink a photo on this device to at most 1280 px and re-encode it as JPEG. */ | |
| function shrink(file) { | |
| return new Promise(function (res) { | |
| if (!/^image\//.test(file.type || "image/")) return res(null); | |
| var url = URL.createObjectURL(file), img = new Image(); | |
| img.onload = function () { | |
| var s = Math.min(1, 1280 / Math.max(img.naturalWidth, img.naturalHeight)) || 1; | |
| var w = Math.max(1, Math.round(img.naturalWidth * s)), h = Math.max(1, Math.round(img.naturalHeight * s)); | |
| var cv = document.createElement("canvas"); cv.width = w; cv.height = h; | |
| var g = cv.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); g.drawImage(img, 0, 0, w, h); | |
| URL.revokeObjectURL(url); res({ data: cv.toDataURL("image/jpeg", 0.74), w: w, h: h }); | |
| }; | |
| img.onerror = function () { URL.revokeObjectURL(url); toast("That file could not be read as an image."); res(null); }; | |
| img.src = url; | |
| }); | |
| } | |
| /* ---------- properties + tenants ---------- */ | |
| function renderSetup() { | |
| $("s-currency").innerHTML = R.CURRENCIES.map(function (c) { return '<option value="' + c.code + '">' + c.code + " · " + esc(c.name) + "</option>"; }).join(""); | |
| toForm(qa("form[data-form=landlord]")[0]); | |
| $("s-props").innerHTML = S.properties.length ? '<div class="list">' + S.properties.map(function (p) { | |
| var n = S.tenants.filter(function (t) { return t.propertyId === p.id; }).length; | |
| return '<div class="li"><div><b>' + esc(p.name) + "</b><small>" + esc(p.address) + " · " + n + " tenant" + (n === 1 ? "" : "s") + '</small></div><button type="button" class="icon-btn" data-delprop="' + esc(p.id) + '" aria-label="Delete ' + esc(p.name) + '">×</button></div>'; | |
| }).join("") + "</div>" : '<p class="hint">No properties yet.</p>'; | |
| $("s-tenants").innerHTML = S.tenants.length ? '<div class="list">' + S.tenants.map(function (t) { | |
| return '<div class="li"><div><b>' + esc(tenantLabel(t)) + "</b><small>" + (t.rent ? esc(R.money(R.parseNum(t.rent), S.currency)) + " due day " + esc(t.dueDay || "1") : "") + (t.email ? " · " + esc(t.email) : "") + | |
| '</small></div><button type="button" class="btn ghost sm" data-edtenant="' + esc(t.id) + '">Edit</button><button type="button" class="icon-btn" data-deltenant="' + esc(t.id) + '" aria-label="Delete ' + esc(t.name) + '">×</button></div>'; | |
| }).join("") + "</div>" : '<p class="hint">No tenants yet.</p>'; | |
| $("n-prop").innerHTML = S.properties.map(function (p) { return '<option value="' + esc(p.id) + '">' + esc(p.name) + "</option>"; }).join("") || '<option value="">Add a property first</option>'; | |
| var q = $("s-quota"); | |
| q.textContent = pro() ? "Pro: unlimited properties and tenants." : "Free plan: " + Math.min(S.properties.length, 1) + " of " + R.FREE.properties + " property and " + Math.min(S.tenants.length, R.FREE.tenants) + " of " + R.FREE.tenants + " tenants used. Pro makes them unlimited."; | |
| q.className = "quota" + (!pro() && (S.properties.length >= R.FREE.properties || S.tenants.length >= R.FREE.tenants) ? " out" : ""); | |
| } | |
| $("f-prop").addEventListener("submit", function (e) { | |
| e.preventDefault(); | |
| var name = $("p-name").value.trim(), address = $("p-address").value.trim(); | |
| if (!pro() && S.properties.length >= R.FREE.properties) return upsell("The free plan holds 1 property. Pro adds unlimited properties and tenants.", "property-limit"); | |
| if (!name) { $("p-name").focus(); return toast("Give the property a name, e.g. its street address."); } | |
| S.properties.push({ id: R.uid("p"), name: name, address: address || name }); | |
| $("p-name").value = ""; $("p-address").value = ""; | |
| saveNow(); renderAll(); toast("Added " + name + "."); | |
| }); | |
| var editing = ""; | |
| $("f-tenant").addEventListener("submit", function (e) { | |
| e.preventDefault(); | |
| var f = this, v = {}; | |
| qa("[name]", f).forEach(function (el) { v[el.name] = el.value.trim(); }); | |
| if (!v.name) { $("n-name").focus(); return toast("Add the tenant's name."); } | |
| if (!S.properties.length) return toast("Add a property first."); | |
| if (!editing && !pro() && S.tenants.length >= R.FREE.tenants) return upsell("The free plan holds 2 tenants. Pro adds unlimited properties and tenants.", "tenant-limit"); | |
| if (editing) { var t = tenant(editing); if (t) Object.keys(v).forEach(function (k) { t[k] = v[k]; }); } | |
| else S.tenants.push(Object.assign({ id: R.uid("t") }, v)); | |
| editing = ""; f.reset(); $("btn-add-tenant").textContent = "Add tenant"; $("btn-tenant-cancel").hidden = true; | |
| saveNow(); renderAll(); toast("Saved " + v.name + "."); | |
| }); | |
| $("btn-tenant-cancel").addEventListener("click", function () { editing = ""; $("f-tenant").reset(); this.hidden = true; $("btn-add-tenant").textContent = "Add tenant"; }); | |
| $("panel-setup").addEventListener("click", function (e) { | |
| var b = e.target.closest("[data-delprop],[data-deltenant],[data-edtenant]"); if (!b) return; | |
| if (b.dataset.delprop) { | |
| var p = prop(b.dataset.delprop), n = S.tenants.filter(function (t) { return t.propertyId === p.id; }); | |
| if (!confirm("Delete " + p.name + (n.length ? " and its " + n.length + " tenant(s)" : "") + "?")) return; | |
| S.properties = S.properties.filter(function (x) { return x.id !== p.id; }); | |
| S.tenants = S.tenants.filter(function (t) { return t.propertyId !== p.id; }); | |
| } else if (b.dataset.deltenant) { | |
| var t = tenant(b.dataset.deltenant); | |
| if (!confirm("Delete " + t.name + "? Their ledger is deleted too.")) return; | |
| S.tenants = S.tenants.filter(function (x) { return x.id !== t.id; }); delete S.ledgers[t.id]; | |
| } else { | |
| var te = tenant(b.dataset.edtenant); editing = te.id; | |
| qa("#f-tenant [name]").forEach(function (el) { el.value = te[el.name] || ""; }); | |
| $("btn-add-tenant").textContent = "Save tenant"; $("btn-tenant-cancel").hidden = false; $("n-name").focus(); | |
| return; | |
| } | |
| saveNow(); renderAll(); | |
| }); | |
| /* logo */ | |
| $("f-logo").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Your logo on every document is part of Pro.", "logo"); } }); | |
| $("f-logo").addEventListener("change", function () { | |
| var f = this.files && this.files[0]; this.value = ""; | |
| if (!f) return; | |
| if (f.size > 8e6) return toast("That image is over 8 MB. Try a smaller logo."); | |
| shrink(f).then(function (p) { | |
| if (!p) return; | |
| var s = Math.min(1, 600 / p.w, 240 / p.h); | |
| if (s < 1) { | |
| var img = new Image(); img.onload = function () { | |
| var cv = document.createElement("canvas"); cv.width = Math.round(p.w * s); cv.height = Math.round(p.h * s); | |
| cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height); | |
| S.logo = { data: cv.toDataURL("image/jpeg", 0.9), w: cv.width, h: cv.height }; saveNow(); logoUi(); renderPreview(); toast("Logo added."); | |
| }; img.src = p.data; | |
| } else { S.logo = p; saveNow(); logoUi(); renderPreview(); toast("Logo added."); } | |
| }); | |
| }); | |
| $("btn-logo-clear").addEventListener("click", function () { S.logo = null; saveNow(); logoUi(); renderPreview(); }); | |
| function logoUi() { | |
| var has = pro() && S.logo && S.logo.data; | |
| $("logo-prev").innerHTML = has ? '<img src="' + S.logo.data + '" alt="Your logo">' : "No logo"; | |
| $("btn-logo-clear").hidden = !has; | |
| } | |
| /* ---------- preview ---------- */ | |
| function currentSpec() { | |
| var c = ctx(); | |
| if (docTab === "ledger") { var t = tenant(S.ledgerTenant); return { kind: "ledger", spec: R.ledgerSpec(t, t && prop(t.propertyId), t ? S.ledgers[t.id] || [] : [], c) }; } | |
| if (docTab === "letter") return { kind: "letter", spec: R.letterSpec(S.letter, c) }; | |
| if (docTab === "checklist") { | |
| var cl = checklist(); | |
| if (cmpMode) { | |
| var a = S.checklists.filter(function (x) { return x.id === S.cmp.in; })[0], b = S.checklists.filter(function (x) { return x.id === S.cmp.out; })[0]; | |
| if (a && b) return { kind: "compare", spec: R.compareSpec(a, b, c) }; | |
| } | |
| return { kind: "checklist", spec: R.checklistSpec(cl || R.newChecklist("in"), c) }; | |
| } | |
| return { kind: "receipt", spec: R.receiptSpec(S.receipt, c) }; | |
| } | |
| function blockHtml(b) { | |
| switch (b.t) { | |
| case "head": | |
| var logo = b.logo && pro() && S.logo && S.logo.data ? '<img class="d-logo" src="' + S.logo.data + '" alt="">' : "", f = b.from || {}; | |
| return '<header class="d-head"><div class="d-from">' + logo + (f.name ? '<div class="d-name">' + esc(f.name) + "</div>" : "") + '<div class="d-lines">' + (f.lines || []).map(esc).join("\n") + "</div></div>" + | |
| '<div class="d-meta">' + (b.title ? '<div class="d-title' + (b.small ? " small" : "") + '">' + esc(b.title) + "</div>" : "") + "<table>" + | |
| (b.meta || []).filter(function (m) { return m[1]; }).map(function (m) { return "<tr><td>" + esc(m[0]) + "</td><td>" + esc(m[1]) + "</td></tr>"; }).join("") + "</table></div></header>"; | |
| case "kv": | |
| return '<div class="d-kv">' + b.rows.map(function (r) { return "<div><span>" + esc(r[0]) + "</span><p" + (r[1] ? "" : ' class="empty"') + ">" + esc(r[1] || "-") + "</p></div>"; }).join("") + "</div>"; | |
| case "amount": | |
| return '<div class="d-amount"><div><span>' + esc(b.label) + "</span>" + (b.sub ? "<p>" + esc(b.sub) + "</p>" : "") + "</div><div><strong>" + esc(b.value) + "</strong>" + (b.status ? '<em class="' + (b.ok ? "ok" : "bad") + '">' + esc(b.status) + "</em>" : "") + "</div></div>"; | |
| case "h": return b.plain ? '<h3 class="d-subj">' + esc(b.text) + "</h3>" : '<h3 class="d-h">' + esc(b.text) + "</h3>"; | |
| case "p": return '<p class="d-p' + (b.small ? " small" : "") + '">' + esc(b.text) + "</p>"; | |
| case "space": return '<div style="height:' + Math.round((b.h || 10) * 1.33) + 'px"></div>'; | |
| case "table": | |
| var cell = function (c, i, tag) { var o = c && typeof c === "object" ? c : { v: c }; return "<" + tag + (b.cols[i].a === "r" ? ' class="r"' : "") + (o.color || o.bold ? ' style="' + (o.color ? "color:" + o.color + ";" : "") + (o.bold ? "font-weight:700" : "") + '"' : "") + ">" + esc(o.v) + "</" + tag + ">"; }; | |
| return '<table class="d-t"><thead><tr>' + b.cols.map(function (c) { return "<th" + (c.a === "r" ? ' class="r"' : "") + ' style="width:' + Math.round(c.w * 100) + '%">' + esc(c.t) + "</th>"; }).join("") + "</tr></thead><tbody>" + | |
| b.rows.map(function (r) { return "<tr>" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + "</tr>"; }).join("") + "</tbody>" + | |
| (b.foot ? "<tfoot>" + b.foot.map(function (r) { return "<tr>" + r.map(function (c, i) { return cell(c, i, "td"); }).join("") + "</tr>"; }).join("") + "</tfoot>" : "") + "</table>"; | |
| case "photos": | |
| return '<div class="d-photos">' + b.items.map(function (it) { var p = it.key && PH.get(it.key); return '<figure><div class="ph">' + (p ? '<img src="' + p.data + '" alt="">' : esc(it.empty || "No photo")) + "</div><figcaption>" + esc(it.caption) + "</figcaption></figure>"; }).join("") + "</div>"; | |
| case "sign": | |
| return '<div class="d-sign">' + b.items.map(function (it) { return "<div><b>" + esc(it.name || "") + "</b><i>" + esc(it.date || "") + "</i><span>" + esc(it.label) + "</span><span>Date</span></div>"; }).join("") + "</div>"; | |
| } | |
| return ""; | |
| } | |
| var lastSpec = null; | |
| function renderPreview() { | |
| var cur = currentSpec(); lastSpec = cur; | |
| var el = $("doc"); | |
| el.className = "paper" + (page() === "A4" ? " a4" : ""); | |
| el.innerHTML = cur.spec.blocks.map(blockHtml).join("") + (pro() ? "" : '<footer class="d-credit">' + esc(CREDIT) + "</footer>"); | |
| $("btn-pdf").textContent = cur.kind === "compare" ? "Download comparison PDF" : cur.kind === "ledger" ? "Download statement PDF" : "Download PDF"; | |
| quotaLine(cur.kind); | |
| fit(); | |
| } | |
| function fit() { | |
| var wrap = $("paper-wrap"), el = $("doc"); | |
| var s = Math.min(1, wrap.clientWidth / el.offsetWidth); | |
| el.style.transform = "scale(" + s + ")"; | |
| wrap.style.height = Math.ceil(el.offsetHeight * s) + "px"; | |
| } | |
| window.addEventListener("resize", fit); | |
| function quotaLine(kind) { | |
| var el = $("quota"), t, out = false; | |
| if (pro()) t = { receipt: "Pro: no footer; the receipt is numbered and saved to history.", ledger: "Pro: statement PDF and CSV export.", letter: "Pro: no footer line.", checklist: "Pro: photos embedded, no footer.", compare: "Pro: comparison report with photos side by side." }[kind]; | |
| else { | |
| t = { receipt: "Free: unlimited receipts with a small Rentsprig footer. Pro removes it and numbers receipts for you.", ledger: "The rent ledger PDF and CSV are part of Pro.", letter: "Free: unlimited letters with a small Rentsprig footer.", | |
| checklist: "Free: " + Math.min(S.checklists.length, 1) + " of 1 move-in checklist used, without photos.", compare: "The comparison report is part of Pro." }[kind]; | |
| out = kind === "ledger" || kind === "compare"; | |
| } | |
| el.textContent = t; el.className = "quota" + (out ? " out" : ""); | |
| } | |
| var changeT = null; | |
| function changed() { save(); clearTimeout(changeT); changeT = setTimeout(renderPreview, 60); } | |
| /* ---------- PDF + print ---------- */ | |
| function bytes(dataUrl) { | |
| var b = atob(String(dataUrl).split(",")[1] || ""), u = new Uint8Array(b.length); | |
| for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); | |
| return u; | |
| } | |
| function gate(kind) { | |
| if (pro()) return true; | |
| if (kind === "ledger") { upsell("The rent ledger statement and CSV are part of Pro.", "ledger"); return false; } | |
| if (kind === "compare") { upsell("The move-out comparison report is part of Pro.", "compare"); return false; } | |
| return true; | |
| } | |
| function pdfBytes(cur) { | |
| cur = cur || currentSpec(); | |
| var imgs = {}; | |
| if (pro()) R.specPhotos(cur.spec).forEach(function (k) { var p = PH.get(k); if (p) imgs[k] = { bytes: bytes(p.data), w: p.w, h: p.h }; }); | |
| var logo = pro() && S.logo && S.logo.data ? { bytes: bytes(S.logo.data), w: S.logo.w, h: S.logo.h } : null; | |
| return P.build(cur.spec, { page: page(), credit: !pro(), creditText: CREDIT, creditUrl: HUB, logo: logo, images: imgs, author: S.landlord.name || "" }); | |
| } | |
| $("btn-pdf").addEventListener("click", function () { | |
| var cur = currentSpec(); | |
| if (!gate(cur.kind)) return; | |
| if (cur.kind === "receipt" && pro()) { recordReceipt(); cur = currentSpec(); } | |
| var out; | |
| try { out = pdfBytes(cur); } catch (e) { return toast("Sorry, the PDF could not be made: " + e.message); } | |
| X.download(R.fileName(cur.spec), new Blob([out], { type: "application/pdf" })); | |
| result(cur.kind === "receipt" && pro() ? "PDF downloaded. Receipt " + S.receipt.no + " saved to history." : "PDF downloaded."); | |
| }); | |
| $("m-pdf").addEventListener("click", function () { $("btn-pdf").click(); }); | |
| $("btn-print").addEventListener("click", function () { | |
| var cur = currentSpec(); | |
| if (!gate(cur.kind)) return; | |
| if (cur.kind === "receipt" && pro()) recordReceipt(); | |
| renderPreview(); result(null); | |
| setTimeout(function () { window.print(); }, 30); | |
| }); | |
| function result(msg) { | |
| S.results = (S.results || 0) + 1; saveNow(); | |
| if (!pro()) $("pro-cta").hidden = false; | |
| if (msg) toast(msg); | |
| } | |
| /* ---------- example, backup ---------- */ | |
| function hasOwnData() { return S.properties.some(function (p) { return p.id !== "p-alder"; }) || S.tenants.some(function (t) { return !/^t-(ellis|nair)$/.test(t.id); }); } | |
| $("btn-example").addEventListener("click", function () { | |
| if (hasOwnData() && !confirm("Load the example? It replaces your properties, tenants and documents in this browser.")) return; | |
| var ex = R.example(); | |
| S.landlord = ex.landlord; S.properties = ex.properties; S.tenants = ex.tenants; S.ledgers = ex.ledgers; S.letter = ex.letter; S.receipt = ex.receipt; S.receipt.post = true; | |
| S.currency = "USD"; | |
| S.checklists = pro() ? ex.checklists : [ex.checklists[0]]; | |
| S.clId = ex.checklists[0].id; S.cmp = { in: ex.checklists[0].id, out: pro() ? ex.checklists[1].id : "" }; | |
| S.ledgerTenant = ex.tenants[0].id; cmpMode = false; | |
| if (pro()) noteNumber(S.receipt.no); | |
| saveNow(); renderAll(); | |
| if (S.tab === "setup") switchTab("receipt"); | |
| toast("Example loaded: a duplex with two tenants."); | |
| if (window.innerWidth < 980) $("preview").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| $("btn-backup").addEventListener("click", function () { | |
| if (!pro()) return upsell("JSON backup of all your data is part of Pro.", "backup"); | |
| saveNow(); | |
| X.download("rentsprig-backup-" + R.todayISO() + ".json", JSON.stringify({ app: "rentsprig", version: 1, saved: new Date().toISOString(), data: S, photos: PH.all() }), "application/json"); | |
| }); | |
| $("f-restore").addEventListener("change", function () { | |
| var f = this.files && this.files[0]; this.value = ""; | |
| if (!f) return; | |
| f.text().then(function (t) { | |
| var j = JSON.parse(t), d = j && j.app === "rentsprig" ? j.data : null; | |
| if (!d || typeof d !== "object" || !Array.isArray(d.tenants)) throw new Error("not a Rentsprig backup"); | |
| var f0 = fresh(); Object.keys(f0).forEach(function (k) { if (d[k] == null) d[k] = f0[k]; }); | |
| S = d; if (!S.receipt) S.receipt = blankReceipt(); if (!S.letter) S.letter = blankLetter(); | |
| return Promise.all(Object.keys(j.photos || {}).map(function (k) { return PH.put(k, j.photos[k]); })); | |
| }).then(function () { saveNow(); renderAll(); toast("Backup restored."); }) | |
| .catch(function (e) { toast("Could not restore: " + e.message + "."); }); | |
| }); | |
| $("btn-wipe").addEventListener("click", function () { | |
| if (!confirm("Clear all Rentsprig data and photos in this browser? This cannot be undone.")) return; | |
| S = fresh(); S.receipt = blankReceipt(); S.letter = blankLetter(); PH.clear(); saveNow(); renderAll(); toast("All data cleared."); | |
| }); | |
| document.addEventListener("click", function (e) { var b = e.target.closest("[data-up]"); if (b) upsell(null, b.dataset.up); }); | |
| $("btn-cta").addEventListener("click", function () { upsell(null, "mid-cta"); }); | |
| /* ---------- render everything ---------- */ | |
| function renderPanel() { | |
| if (S.tab === "ledger") renderLedger(); | |
| if (S.tab === "checklist") { renderPicker(); renderRooms(); } | |
| if (S.tab === "setup") renderSetup(); | |
| } | |
| function renderAll() { | |
| $("r-method").innerHTML = R.METHODS.map(function (m) { return "<option>" + m + "</option>"; }).join(""); | |
| if (S.tab === "checklist") ensureChecklist(); | |
| renderTenantSelects(); allForms(); renderReceiptHistory(); renderLedger(); renderSetup(); logoUi(); | |
| if (S.checklists.length) { renderPicker(); renderRooms(); } | |
| renderPreview(); | |
| } | |
| /* ---------- Pro state ---------- */ | |
| function onPro(isPro) { | |
| $("pro-pill").textContent = isPro ? "Pro ✓" : "Go Pro"; | |
| if (isPro) { $("pro-cta").hidden = true; if (!S.receipt.no) S.receipt.no = R.receiptNo((S.counter || 0) + 1); } | |
| renderAll(); | |
| } | |
| /* ---------- install + offline ---------- */ | |
| var deferred = null; | |
| window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("btn-install").hidden = false; }); | |
| $("btn-install").addEventListener("click", function () { | |
| if (!deferred) return; | |
| deferred.prompt(); deferred.userChoice.finally(function () { deferred = null; $("btn-install").hidden = true; }); | |
| }); | |
| window.addEventListener("appinstalled", function () { $("btn-install").hidden = true; }); | |
| if ("serviceWorker" in navigator && (location.protocol === "https:" || /^(localhost|127\.0\.0\.1)$/.test(location.hostname))) { | |
| window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js").catch(function () { /* offline cache is optional */ }); }); | |
| } | |
| /* ---------- start ---------- */ | |
| switchTabSilently(S.tab); | |
| function switchTabSilently(t) { | |
| qa(".tabs [role=tab]").forEach(function (b) { var on = b.dataset.tab === t; b.setAttribute("aria-selected", String(on)); b.tabIndex = on ? 0 : -1; }); | |
| qa(".panel").forEach(function (p) { p.hidden = p.id !== "panel-" + t; }); | |
| } | |
| PH.loadAll().then(function () { | |
| X.onChange(onPro); | |
| window.Rentsprig = { state: function () { return S; }, pdf: function () { return pdfBytes(); }, ready: true }; | |
| }); | |
| })(); | |