- migration/README.md: cold-start rebuild runbook (reconciliation gate,
classification rules, transfer pairing, investment policy, execution order)
- migration/build_rebuild_dataset.py: consolidated 3-QFX builder with PNC-
owned transfers, counterpart pairing & drop, per-account reconciliation
- migration/rebuild_clusters.{json,md}: clustering proposal for the rebuild
- migration/rebuild_review.html: read-only browser review for the 1017-txn
rebuild plan (transfers under PNC, category fixes baked in)
- migration/{pnc_review,review_preview_mixed}.html: earlier UI previews
- merchant_map.json: add 10 settled deterministic rules (Duquesne Light,
Pitt Salary, Interest Payment, IRS, Pitt Tuition, Daily Cash Adjustment,
ATM Surcharge/Yardi/Venmo/Zelle->Don't Know) so the skill stops flagging
pre-classified PNC lines as UNMATCHED
379 lines
21 KiB
HTML
379 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Firefly import review</title>
|
||
<style>
|
||
:root { --bg:#0f1115; --panel:#181b22; --line:#2a2f3a; --fg:#e7e9ee;
|
||
--muted:#9aa3b2; --accent:#4f9cf9; --ok:#3fb950; --no:#f85149;
|
||
--warn:#d29922; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;
|
||
background:var(--bg); color:var(--fg); }
|
||
header { position:sticky; top:0; z-index:5; background:var(--panel);
|
||
border-bottom:1px solid var(--line); padding:12px 18px;
|
||
display:flex; gap:18px; align-items:center; flex-wrap:wrap; }
|
||
header h1 { font-size:15px; margin:0; font-weight:600; }
|
||
.counts { display:flex; gap:14px; color:var(--muted); font-size:13px; }
|
||
.counts b { color:var(--fg); }
|
||
.spacer { flex:1; }
|
||
button { font:inherit; cursor:pointer; border:1px solid var(--line);
|
||
background:#222732; color:var(--fg); border-radius:6px;
|
||
padding:7px 12px; }
|
||
button:hover { border-color:var(--accent); }
|
||
button.primary { background:var(--accent); border-color:var(--accent);
|
||
color:#06203f; font-weight:600; }
|
||
section { margin:22px auto; max-width:1200px; padding:0 18px; }
|
||
h2 { font-size:15px; border-bottom:1px solid var(--line);
|
||
padding-bottom:6px; }
|
||
h2 .sub { color:var(--muted); font-weight:400; font-size:12px;
|
||
margin-left:8px; }
|
||
table { width:100%; border-collapse:collapse; }
|
||
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line);
|
||
vertical-align:top; font-size:13px; }
|
||
th { color:var(--muted); font-weight:600; }
|
||
td.amt { text-align:right; font-variant-numeric:tabular-nums;
|
||
white-space:nowrap; }
|
||
.desc { color:var(--fg); }
|
||
.desc small { color:var(--muted); display:block; }
|
||
input[type=text], select { font:inherit; background:#11141a; color:var(--fg);
|
||
border:1px solid var(--line); border-radius:5px; padding:5px 6px;
|
||
width:100%; }
|
||
.toggle { display:inline-flex; border:1px solid var(--line);
|
||
border-radius:6px; overflow:hidden; }
|
||
.toggle button { border:0; border-radius:0; padding:5px 10px;
|
||
background:#11141a; }
|
||
.toggle button.on-ok { background:var(--ok); color:#04210b;
|
||
font-weight:600; }
|
||
.toggle button.on-no { background:var(--no); color:#2a0606;
|
||
font-weight:600; }
|
||
.pill { font-size:11px; padding:1px 7px; border-radius:999px;
|
||
border:1px solid var(--line); color:var(--muted); }
|
||
.pill.review { color:var(--warn); border-color:var(--warn); }
|
||
.pill.unmatched { color:var(--no); border-color:var(--no); }
|
||
.pill.create { color:var(--ok); border-color:var(--ok); }
|
||
.pill.transfer { color:#fff; background:#8e5cf7; border-color:#8e5cf7; }
|
||
.pill.dup { color:var(--muted); }
|
||
tr.is-transfer td:first-child { box-shadow: inset 3px 0 0 #8e5cf7; }
|
||
.amt .dir { font-weight:700; margin-right:2px; }
|
||
.amt .io { font-size:9px; letter-spacing:.05em; opacity:.65;
|
||
margin-left:4px; vertical-align:1px; }
|
||
.amt.in { color:#3fb950; }
|
||
.amt.out { color:#f0883e; }
|
||
.amt.xfer{ color:#a983f7; }
|
||
tr.grp td { background:#1b2030; font-weight:700; color:#9fb3c8;
|
||
padding:7px 9px; border-top:2px solid #2a3140;
|
||
letter-spacing:.02em; }
|
||
tr.grp .sub { color:var(--muted); font-weight:400; }
|
||
.sugg { color:var(--muted); font-size:12px; margin-top:3px; }
|
||
.sugg a { color:var(--accent); cursor:pointer; text-decoration:underline; }
|
||
.readonly td { color:var(--muted); }
|
||
.empty { color:var(--muted); padding:10px 0; }
|
||
footer { max-width:1200px; margin:30px auto 60px; padding:0 18px;
|
||
color:var(--muted); font-size:12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Firefly import review</h1>
|
||
<div class="counts" id="counts"></div>
|
||
<div class="spacer"></div>
|
||
<button id="approveAuto">Approve all auto</button>
|
||
<button id="export" class="primary">Export decisions</button>
|
||
</header>
|
||
|
||
<section>
|
||
<h2>Needs Clarity <span class="sub">UNMATCHED + REVIEW — set the
|
||
canonical account, category, budget, then approve</span></h2>
|
||
<table><thead><tr>
|
||
<th>Date</th><th>Amount</th><th>Description</th><th>Account</th>
|
||
<th>Category</th><th>Budget</th><th>Comment</th><th>Decision</th>
|
||
</tr></thead><tbody id="tb-clarity"></tbody></table>
|
||
<div class="empty" id="empty-clarity" hidden>Nothing needs clarification.</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>Auto-Proposed <span class="sub">matched a rule — approved by
|
||
default, deny or edit if wrong</span></h2>
|
||
<table><thead><tr>
|
||
<th>Date</th><th>Amount</th><th>Description</th><th>Account</th>
|
||
<th>Category</th><th>Budget</th><th>Comment</th><th>Decision</th>
|
||
</tr></thead><tbody id="tb-auto"></tbody></table>
|
||
<div class="empty" id="empty-auto" hidden>No auto-proposed rows.</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>All Transactions <span class="sub">complete picture, read-only
|
||
(includes skipped duplicates)</span></h2>
|
||
<table><thead><tr>
|
||
<th>Date</th><th>Amount</th><th>Type</th><th>Description</th>
|
||
<th>Bucket</th><th>Target</th>
|
||
</tr></thead><tbody id="tb-all"></tbody></table>
|
||
</section>
|
||
|
||
<footer>
|
||
Decisions are exported as <code>decisions.json</code> (keyed by
|
||
external_id). Re-run
|
||
<code>firefly_import.py <normalized> --decisions decisions.json --post</code>
|
||
to post only approved rows.
|
||
</footer>
|
||
|
||
<script>
|
||
const PLAN = {"meta": {"generated": "2026-05-17", "config": ".firefly.json", "record_count": 13, "note": "Transfers all owned by PNC leg (PNC date/FITID); card-side counterparts dropped", "categories": ["Adjustment", "Amazon", "Auto: Fees", "Auto: Fuel", "Auto: Insurance", "Auto: Parking", "Clothes", "Coffee", "Education", "Entertainment", "Groceries", "Home", "Medical", "Other", "Personal Care", "Pets", "Recreation: Firearms", "Recreation: Racing", "Rent", "Restaurants", "Subscriptions", "Taxes", "Tools", "Travel", "Utilities: Electric", "Wages", "WRX (Aimee)", "Z (Mizumi)"], "accounts": ["PNC Checking", "Apple Credit Card", "Costco Visa Card", "Schwab Stocks", "Schwab Savings", "Cash", "Illiquid Assets", "Don't Know", "Sheetz", "Amazon", "Costco", "Costco Gas", "Duquesne Light", "Local Cafe", "Tazza D'Oro"], "rendered": "2026-05-17T15:09:50"}, "rows": [{"external_id": "pnc:f1", "bucket": "TRANSFER", "date": "2026-06-02", "amount": "1650.72", "type": "transfer", "description": "APPLECARD GSBANK PAYMENT ACH WEB-RECUR", "asset_account": "PNC Checking", "destination_account": "Apple Credit Card", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:f2", "bucket": "TRANSFER", "date": "2026-06-15", "amount": "116.09", "type": "transfer", "description": "CITI AUTOPAY PAYMENT ACH WEB-REC", "asset_account": "PNC Checking", "destination_account": "Costco Visa Card", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:f3", "bucket": "TRANSFER", "date": "2026-06-03", "amount": "3000.00", "type": "transfer", "description": "ONLINE TRANSFER TO SCHWAB MONEYLINK", "asset_account": "PNC Checking", "destination_account": "Schwab Stocks", "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "pnc:e1", "bucket": "CREATE", "date": "2026-06-09", "amount": "52.18", "type": "withdrawal", "description": "DUQUESNE LIGHT PAYMENT", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": "Duquesne Light", "proposed_account_id": 701, "proposed_category": "Utilities: Electric", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "pnc:r1", "bucket": "REVIEW", "date": "2026-06-08", "amount": "300.00", "type": "withdrawal", "description": "CHECK 145 xxxxx9921", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": [], "budget_suggestions": []}, {"external_id": "pnc:u1", "bucket": "UNMATCHED", "date": "2026-06-04", "amount": "128.44", "type": "withdrawal", "description": "SQ *BRDEJ VENTURES 8773319635 CA", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": ["Don't Know"], "review": false, "category_suggestions": [], "budget_suggestions": []}, {"external_id": "pnc:inc1", "bucket": "CREATE", "date": "2026-06-05", "amount": "2778.70", "type": "deposit", "description": "UNIV PITTSBURGH PAYROLL ACH CREDIT", "asset_account": "PNC Checking", "destination_account": null, "proposed_account": "Pitt Salary", "proposed_account_id": 505, "proposed_category": "Wages", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": []}, {"external_id": "apple_blue:320260601AX01", "bucket": "SKIP-dup", "date": "2026-06-01", "amount": "44.10", "type": "withdrawal", "description": "SHEETZ 0481 PETRO BEDFORD PA (re-download)", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Sheetz", "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": false}, {"external_id": "ap:1", "bucket": "CREATE", "date": "2026-06-01", "amount": "44.10", "type": "withdrawal", "description": "SHEETZ 0481 PETRO BEDFORD PA", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Sheetz", "proposed_account_id": 566, "proposed_category": "Auto: Fuel", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "ap:2", "bucket": "CREATE", "date": "2026-06-03", "amount": "58.99", "type": "withdrawal", "description": "AMAZON MKTPL*Z9", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": "Amazon", "proposed_account_id": 548, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": ["Amazon"], "budget_suggestions": []}, {"external_id": "ap:4", "bucket": "UNMATCHED", "date": "2026-06-07", "amount": "14.25", "type": "withdrawal", "description": "TST* THE COMMONPLACE CAFE", "asset_account": "Apple Credit Card", "destination_account": null, "proposed_account": null, "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": ["Local Cafe", "Tazza D'Oro"], "review": false, "category_suggestions": ["Coffee", "Restaurants"], "budget_suggestions": ["Wants"]}, {"external_id": "co:1", "bucket": "CREATE", "date": "2026-06-06", "amount": "187.44", "type": "withdrawal", "description": "COSTCO WHSE #1234 PITTSBURGH", "asset_account": "Costco Visa Card", "destination_account": null, "proposed_account": "Costco", "proposed_account_id": 512, "proposed_category": "Groceries", "proposed_budget": null, "suggestions": [], "review": false, "budget_suggestions": ["Needs"]}, {"external_id": "co:3", "bucket": "REVIEW", "date": "2026-06-10", "amount": "73.20", "type": "withdrawal", "description": "COSTCO GAS #0332", "asset_account": "Costco Visa Card", "destination_account": null, "proposed_account": "Costco Gas", "proposed_account_id": null, "proposed_category": null, "proposed_budget": null, "suggestions": [], "review": true, "category_suggestions": ["Auto: Fuel"], "budget_suggestions": ["Needs"]}]};
|
||
const BUDGETS = ["", "Needs", "Wants", "Savings"];
|
||
const rows = PLAN.rows || [];
|
||
|
||
// Per-row decision state, keyed by external_id (the stable id).
|
||
const state = {};
|
||
for (const r of rows) {
|
||
const isAuto = r.bucket === "CREATE" || r.bucket === "TRANSFER";
|
||
state[r.external_id] = {
|
||
approved: isAuto, // auto rows default-approved; clarity not
|
||
account: r.proposed_account || "",
|
||
account_id: (r.proposed_account_id != null) ? r.proposed_account_id : null,
|
||
category: r.proposed_category || "",
|
||
budget: r.proposed_budget || "",
|
||
comment: ""
|
||
};
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||
}
|
||
|
||
// Amount cell with explicit direction: deposit = green +in, withdrawal =
|
||
// red -out, transfer = purple between-accounts.
|
||
function amtCell(r) {
|
||
const t = r.type;
|
||
const sign = t === "deposit" ? "+" : t === "transfer" ? "⇄" : "−";
|
||
const word = t === "deposit" ? "in" : t === "transfer" ? "xfer" : "out";
|
||
return `<td class="amt ${word}"><span class="dir">${sign}</span>` +
|
||
`$${esc(r.amount)} <span class="io">${word.toUpperCase()}</span></td>`;
|
||
}
|
||
|
||
function budgetOptions(sel) {
|
||
return BUDGETS.map(b =>
|
||
`<option value="${esc(b)}"${b===sel?" selected":""}>${esc(b||"—")}</option>`
|
||
).join("");
|
||
}
|
||
|
||
function decisionCell(id) {
|
||
const s = state[id];
|
||
return `<div class="toggle" data-id="${esc(id)}">
|
||
<button class="d-ok${s.approved?" on-ok":""}">Approve</button>
|
||
<button class="d-no${!s.approved?" on-no":""}">Deny</button></div>`;
|
||
}
|
||
|
||
function editableRow(r) {
|
||
const id = r.external_id, s = state[id];
|
||
const pill = r.bucket === "UNMATCHED"
|
||
? '<span class="pill unmatched">UNMATCHED</span>'
|
||
: r.bucket === "TRANSFER"
|
||
? '<span class="pill transfer">TRANSFER</span>'
|
||
: (r.review ? '<span class="pill review">REVIEW</span>'
|
||
: '<span class="pill create">CREATE</span>');
|
||
const canon = r.bucket === "TRANSFER"
|
||
? `Transfer → <b>${esc(r.destination_account || "?")}</b>`
|
||
: (r.proposed_account
|
||
? `Canonical account → <b>${esc(r.proposed_account)}</b>` +
|
||
(r.proposed_account_id != null
|
||
? ` <span style="color:#888">#${esc(r.proposed_account_id)}</span>`
|
||
: ` <span style="color:#888">(new)</span>`)
|
||
: `Canonical account → <b style="color:#c0392b">unset</b> ` +
|
||
`<span style="color:#888">— set it below</span>`);
|
||
let sugg = "";
|
||
if (r.suggestions && r.suggestions.length) {
|
||
sugg = `<div class="sugg">did you mean: ` +
|
||
r.suggestions.map(x =>
|
||
`<a data-id="${esc(id)}" data-acct="${esc(x)}">${esc(x)}</a>`
|
||
).join(", ") + `</div>`;
|
||
}
|
||
let catsugg = "";
|
||
if (r.category_suggestions && r.category_suggestions.length) {
|
||
catsugg = `<div class="sugg catsugg">try: ` +
|
||
r.category_suggestions.map(x =>
|
||
`<a data-id="${esc(id)}" data-cat="${esc(x)}">${esc(x)}</a>`
|
||
).join(", ") + `</div>`;
|
||
}
|
||
let budsugg = "";
|
||
if (r.budget_suggestions && r.budget_suggestions.length) {
|
||
budsugg = `<div class="sugg budsugg">` +
|
||
r.budget_suggestions.map(x =>
|
||
`<a data-id="${esc(id)}" data-bud="${esc(x)}">${esc(x)}</a>`
|
||
).join(", ") + `</div>`;
|
||
}
|
||
return `<tr data-id="${esc(id)}" class="${r.bucket==="TRANSFER"?"is-transfer":""}">
|
||
<td>${esc(r.date)}</td>
|
||
${amtCell(r)}
|
||
<td class="desc">${esc(r.description)}
|
||
<small>${esc(r.asset_account)} ${pill}</small>
|
||
<div class="canon" style="margin-top:3px;font-size:12px">${canon}</div>${sugg}</td>
|
||
<td><input type="text" class="f-account" list="acctlist"
|
||
value="${esc(s.account)}" placeholder="pick / type account"></td>
|
||
<td><input type="text" class="f-category" list="catlist"
|
||
value="${esc(s.category)}" placeholder="pick existing category">${catsugg}</td>
|
||
<td><select class="f-budget">${budgetOptions(s.budget)}</select>${budsugg}</td>
|
||
<td><input type="text" class="f-comment" value="${esc(s.comment)}"
|
||
placeholder="note"></td>
|
||
<td>${decisionCell(id)}</td></tr>`;
|
||
}
|
||
|
||
function readonlyRow(r) {
|
||
let tgt = "";
|
||
if (r.bucket === "TRANSFER") tgt = "→ " + esc(r.destination_account || "?");
|
||
else if (r.proposed_account) tgt = "→ " + esc(r.proposed_account) +
|
||
(r.proposed_account_id != null ? " #" + esc(r.proposed_account_id)
|
||
: " (new)");
|
||
else if (r.bucket === "SKIP-dup") tgt = "dup";
|
||
const cls = {"SKIP-dup":"dup","UNMATCHED":"unmatched","TRANSFER":"transfer",
|
||
"REVIEW":"review","CREATE":"create"}[r.bucket] || "";
|
||
return `<tr class="readonly${r.bucket==="TRANSFER"?" is-transfer":""}">
|
||
<td>${esc(r.date)}</td>${amtCell(r)}
|
||
<td>${esc(r.type)}</td><td>${esc(r.description)}</td>
|
||
<td><span class="pill ${cls}">${esc(r.bucket)}</span></td>
|
||
<td>${tgt}</td></tr>`;
|
||
}
|
||
|
||
// Group a section's rows by the statement they came from (PNC / Apple /
|
||
// Costco ...) with a subheader per account, so a multi-statement rebuild is
|
||
// reviewable account-by-account instead of one undifferentiated list.
|
||
function groupByAccount(list, rowFn, cols) {
|
||
const order = [], groups = {};
|
||
for (const r of list) {
|
||
const a = r.asset_account || "(unknown)";
|
||
if (!(a in groups)) { groups[a] = []; order.push(a); }
|
||
groups[a].push(r);
|
||
}
|
||
return order.map(a =>
|
||
`<tr class="grp"><td colspan="${cols}">${esc(a)} ` +
|
||
`<span class="sub">${groups[a].length}</span></td></tr>` +
|
||
groups[a].map(rowFn).join("")).join("");
|
||
}
|
||
|
||
function render() {
|
||
const clarity = rows.filter(r =>
|
||
r.bucket === "UNMATCHED" || r.bucket === "REVIEW");
|
||
const auto = rows.filter(r =>
|
||
r.bucket === "CREATE" || r.bucket === "TRANSFER");
|
||
|
||
const tbC = document.getElementById("tb-clarity");
|
||
const tbA = document.getElementById("tb-auto");
|
||
const tbAll = document.getElementById("tb-all");
|
||
tbC.innerHTML = groupByAccount(clarity, editableRow, 8);
|
||
tbA.innerHTML = groupByAccount(auto, editableRow, 8);
|
||
tbAll.innerHTML = groupByAccount(rows, readonlyRow, 6);
|
||
document.getElementById("empty-clarity").hidden = clarity.length > 0;
|
||
document.getElementById("empty-auto").hidden = auto.length > 0;
|
||
|
||
const nApproved = Object.values(state).filter(s => s.approved).length;
|
||
document.getElementById("counts").innerHTML =
|
||
`<span><b>${rows.length}</b> rows</span>` +
|
||
`<span><b>${clarity.length}</b> need clarity</span>` +
|
||
`<span><b>${auto.length}</b> auto</span>` +
|
||
`<span><b>${rows.filter(r=>r.bucket==="SKIP-dup").length}</b> dup</span>` +
|
||
`<span><b>${nApproved}</b> approved</span>`;
|
||
}
|
||
|
||
// --- event wiring (delegated, so re-render keeps working) ------------------
|
||
document.body.addEventListener("input", e => {
|
||
const tr = e.target.closest("tr[data-id]");
|
||
if (!tr) return;
|
||
const id = tr.dataset.id, s = state[id];
|
||
if (e.target.classList.contains("f-account")) {
|
||
s.account = e.target.value; s.account_id = null; // typed name => re-resolve
|
||
} else if (e.target.classList.contains("f-category")) {
|
||
s.category = e.target.value;
|
||
} else if (e.target.classList.contains("f-budget")) {
|
||
s.budget = e.target.value;
|
||
} else if (e.target.classList.contains("f-comment")) {
|
||
s.comment = e.target.value;
|
||
}
|
||
});
|
||
|
||
document.body.addEventListener("click", e => {
|
||
// suggestion chip -> fill account
|
||
if (e.target.matches(".sugg a") && e.target.dataset.acct != null) {
|
||
const id = e.target.dataset.id;
|
||
state[id].account = e.target.dataset.acct;
|
||
state[id].account_id = null;
|
||
render();
|
||
return;
|
||
}
|
||
if (e.target.matches(".catsugg a") && e.target.dataset.cat != null) {
|
||
const id = e.target.dataset.id;
|
||
state[id].category = e.target.dataset.cat;
|
||
render();
|
||
return;
|
||
}
|
||
if (e.target.matches(".budsugg a") && e.target.dataset.bud != null) {
|
||
const id = e.target.dataset.id;
|
||
state[id].budget = e.target.dataset.bud;
|
||
render();
|
||
return;
|
||
}
|
||
const tog = e.target.closest(".toggle");
|
||
if (tog) {
|
||
const id = tog.dataset.id;
|
||
if (e.target.classList.contains("d-ok")) state[id].approved = true;
|
||
else if (e.target.classList.contains("d-no")) state[id].approved = false;
|
||
render();
|
||
}
|
||
});
|
||
|
||
document.getElementById("approveAuto").addEventListener("click", () => {
|
||
for (const r of rows) {
|
||
if (r.bucket === "CREATE" || r.bucket === "TRANSFER")
|
||
state[r.external_id].approved = true;
|
||
}
|
||
render();
|
||
});
|
||
|
||
document.getElementById("export").addEventListener("click", () => {
|
||
// Only emit rows the user can act on (clarity + auto). SKIP-dup rows are
|
||
// read-only context and are never posted, so they stay out of decisions.
|
||
const out = {};
|
||
for (const r of rows) {
|
||
if (r.bucket === "SKIP-dup") continue;
|
||
const s = state[r.external_id];
|
||
out[r.external_id] = {
|
||
approved: !!s.approved,
|
||
account: s.account || null,
|
||
account_id: (s.account_id != null) ? s.account_id : null,
|
||
category: s.category || null,
|
||
budget: s.budget || null,
|
||
comment: s.comment || ""
|
||
};
|
||
}
|
||
const blob = new Blob([JSON.stringify(out, null, 2)],
|
||
{ type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url; a.download = "decisions.json";
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
});
|
||
|
||
// Constrain category/account inputs to what already exists in Firefly, so
|
||
// the user clicks/filters real values instead of free-typing near-duplicates
|
||
// ("Restaurants" vs "Restaurant" vs "Dining"). New values are still possible
|
||
// but it's a deliberate type-through, not the path of least resistance.
|
||
function buildList(id, items) {
|
||
const dl = document.createElement("datalist");
|
||
dl.id = id;
|
||
for (const v of (items || [])) {
|
||
const o = document.createElement("option");
|
||
o.value = v;
|
||
dl.appendChild(o);
|
||
}
|
||
document.body.appendChild(dl);
|
||
}
|
||
buildList("catlist", (PLAN.meta && PLAN.meta.categories) || []);
|
||
buildList("acctlist", (PLAN.meta && PLAN.meta.accounts) || []);
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|