@firedrill-tools/quickbooks 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +240 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +2338 -0
  5. package/firedrill/conformance.suite.json +22 -0
  6. package/firedrill/payment-response-lost.scenario.json +2344 -0
  7. package/firedrill/qbo-company-query.drill.json +143 -0
  8. package/firedrill/qbo-customers-items.drill.json +173 -0
  9. package/firedrill/qbo-default-identity.drill.json +53 -0
  10. package/firedrill/qbo-denied.drill.json +53 -0
  11. package/firedrill/qbo-escape-heavy-invoices.drill.json +160 -0
  12. package/firedrill/qbo-invalid-auth.drill.json +398 -0
  13. package/firedrill/qbo-invoice-lifecycle.drill.json +193 -0
  14. package/firedrill/qbo-large-invoice.drill.json +174 -0
  15. package/firedrill/qbo-mcp-shapes.drill.json +503 -0
  16. package/firedrill/qbo-payment-response-lost.drill.json +80 -0
  17. package/firedrill/qbo-payments.drill.json +110 -0
  18. package/firedrill/qbo-reports-only.drill.json +398 -0
  19. package/firedrill/qbo-roles.drill.json +143 -0
  20. package/firedrill/qbo-throttled.drill.json +398 -0
  21. package/firedrill/qbo-tight-limits.drill.json +368 -0
  22. package/firedrill/qbo-write-outage.drill.json +411 -0
  23. package/firedrill/throttled.scenario.json +2344 -0
  24. package/firedrill/tight-limits.scenario.json +2338 -0
  25. package/firedrill/tools/quickbooks/app/assets/ATTRIBUTION.md +33 -0
  26. package/firedrill/tools/quickbooks/app/assets/fonts/OFL.txt +93 -0
  27. package/firedrill/tools/quickbooks/app/assets/intuit-quickbooks.svg +1 -0
  28. package/firedrill/tools/quickbooks/app/assets/quickbooks.svg +1 -0
  29. package/firedrill/tools/quickbooks/app/site/app.js +157 -0
  30. package/firedrill/tools/quickbooks/app/site/assets/fonts/figtree-latin.woff2 +0 -0
  31. package/firedrill/tools/quickbooks/app/site/assets/intuit-quickbooks.svg +1 -0
  32. package/firedrill/tools/quickbooks/app/site/assets/quickbooks.svg +1 -0
  33. package/firedrill/tools/quickbooks/app/site/base.css +107 -0
  34. package/firedrill/tools/quickbooks/app/site/forms.css +85 -0
  35. package/firedrill/tools/quickbooks/app/site/icons.js +73 -0
  36. package/firedrill/tools/quickbooks/app/site/index.html +81 -0
  37. package/firedrill/tools/quickbooks/app/site/nav.css +99 -0
  38. package/firedrill/tools/quickbooks/app/site/store.js +85 -0
  39. package/firedrill/tools/quickbooks/app/site/tables.css +114 -0
  40. package/firedrill/tools/quickbooks/app/site/ui.js +189 -0
  41. package/firedrill/tools/quickbooks/app/site/views/accounts.js +59 -0
  42. package/firedrill/tools/quickbooks/app/site/views/batch-actions.js +116 -0
  43. package/firedrill/tools/quickbooks/app/site/views/batch.js +91 -0
  44. package/firedrill/tools/quickbooks/app/site/views/common.js +87 -0
  45. package/firedrill/tools/quickbooks/app/site/views/customer-detail.js +85 -0
  46. package/firedrill/tools/quickbooks/app/site/views/customer-drawer.js +71 -0
  47. package/firedrill/tools/quickbooks/app/site/views/customers.js +87 -0
  48. package/firedrill/tools/quickbooks/app/site/views/home.js +79 -0
  49. package/firedrill/tools/quickbooks/app/site/views/invoice-actions.js +109 -0
  50. package/firedrill/tools/quickbooks/app/site/views/invoice-editor.js +126 -0
  51. package/firedrill/tools/quickbooks/app/site/views/invoice-lines.js +92 -0
  52. package/firedrill/tools/quickbooks/app/site/views/invoices.js +118 -0
  53. package/firedrill/tools/quickbooks/app/site/views/items.js +105 -0
  54. package/firedrill/tools/quickbooks/app/site/views/payments.js +63 -0
  55. package/firedrill/tools/quickbooks/app/site/views/receive-payment.js +102 -0
  56. package/firedrill/tools/quickbooks/app/site/views/txn-frame.js +25 -0
  57. package/firedrill/tools/quickbooks/behavior.mjs +10 -0
  58. package/firedrill/tools/quickbooks/lib/access.mjs +167 -0
  59. package/firedrill/tools/quickbooks/lib/common.mjs +204 -0
  60. package/firedrill/tools/quickbooks/lib/criteria.mjs +107 -0
  61. package/firedrill/tools/quickbooks/lib/customers.mjs +140 -0
  62. package/firedrill/tools/quickbooks/lib/fields.mjs +157 -0
  63. package/firedrill/tools/quickbooks/lib/handlers-read.mjs +81 -0
  64. package/firedrill/tools/quickbooks/lib/handlers-write.mjs +95 -0
  65. package/firedrill/tools/quickbooks/lib/invoice-draft.mjs +165 -0
  66. package/firedrill/tools/quickbooks/lib/invoices.mjs +229 -0
  67. package/firedrill/tools/quickbooks/lib/items.mjs +117 -0
  68. package/firedrill/tools/quickbooks/lib/like.mjs +49 -0
  69. package/firedrill/tools/quickbooks/lib/payments.mjs +200 -0
  70. package/firedrill/tools/quickbooks/lib/pdf.mjs +96 -0
  71. package/firedrill/tools/quickbooks/lib/query-eval.mjs +178 -0
  72. package/firedrill/tools/quickbooks/lib/query-parse.mjs +229 -0
  73. package/firedrill/tools/quickbooks/lib/views.mjs +88 -0
  74. package/firedrill/tools/quickbooks/lib/wire.mjs +147 -0
  75. package/firedrill/tools/quickbooks/quickbooks.tool.json +11014 -0
  76. package/firedrill/world.json +559 -0
  77. package/firedrill/write-outage.scenario.json +2344 -0
  78. package/firedrill.json +5 -0
  79. package/package.json +52 -0
  80. package/starter.json +2337 -0
  81. package/test/conformance.mjs +674 -0
@@ -0,0 +1,81 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>QuickBooks (synthetic)</title>
7
+ <link rel="icon" type="image/svg+xml" href="./assets/quickbooks.svg" />
8
+ <link rel="stylesheet" href="./base.css" />
9
+ <link rel="stylesheet" href="./nav.css" />
10
+ <link rel="stylesheet" href="./tables.css" />
11
+ <link rel="stylesheet" href="./forms.css" />
12
+ <script type="module" src="./app.js"></script>
13
+ </head>
14
+ <body>
15
+ <a class="skip-link" href="#page">Skip to content</a>
16
+ <div class="shell" id="shell">
17
+ <aside class="leftnav" id="leftnav" aria-label="QuickBooks navigation">
18
+ <div class="brand"><img src="./assets/intuit-quickbooks.svg" alt="Intuit QuickBooks" class="brand-lockup" /><img src="./assets/quickbooks.svg" alt="Intuit QuickBooks" class="brand-icon" /></div>
19
+ <div class="create-wrap">
20
+ <button type="button" class="create-btn" id="create-btn" aria-haspopup="menu" aria-expanded="false"><span data-icon="plus"></span><span>Create</span></button>
21
+ </div>
22
+ <nav class="navlist" id="navlist">
23
+ <button type="button" class="nav-item" data-not-simulated="Bookmarks"><span data-icon="bookmark" class="nav-icon"></span><span>Bookmarks</span></button>
24
+ <a class="nav-item" href="#/home" data-route="home"><span data-icon="home" class="nav-icon"></span><span>Home</span></a>
25
+ <button type="button" class="nav-item" data-not-simulated="Feed"><span data-icon="feed" class="nav-icon"></span><span>Feed</span></button>
26
+ <button type="button" class="nav-item" data-not-simulated="Reports"><span data-icon="reports" class="nav-icon"></span><span>Reports</span></button>
27
+ <button type="button" class="nav-item" data-not-simulated="All apps"><span data-icon="apps" class="nav-icon"></span><span>All apps</span></button>
28
+ <div class="nav-heading">Pinned</div>
29
+ <a class="nav-item" href="#/accounts" data-route="accounts"><span data-icon="accounting" class="nav-icon"></span><span>Accounting</span></a>
30
+ <button type="button" class="nav-item" data-not-simulated="Expenses &amp; Bills"><span data-icon="expenses" class="nav-icon"></span><span>Expenses &amp; Bills</span></button>
31
+ <div class="nav-group" id="group-sales">
32
+ <a class="nav-item" href="#/invoices" data-route="sales"><span data-icon="sales" class="nav-icon"></span><span>Sales &amp; Get paid</span></a>
33
+ <div class="nav-sub">
34
+ <a class="nav-subitem" href="#/invoices" data-route="invoices">Invoices</a>
35
+ <a class="nav-subitem" href="#/payments" data-route="payments">Payments</a>
36
+ <a class="nav-subitem" href="#/customers" data-route="customers">Customers</a>
37
+ <a class="nav-subitem" href="#/items" data-route="items">Products &amp; services</a>
38
+ </div>
39
+ </div>
40
+ <a class="nav-item" href="#/customers" data-route="customers-hub"><span data-icon="customers" class="nav-icon"></span><span>Customers &amp; leads</span></a>
41
+ <button type="button" class="nav-item" data-not-simulated="Banking"><span data-icon="bank" class="nav-icon"></span><span>Banking</span></button>
42
+ <button type="button" class="nav-item" data-not-simulated="Payroll"><span data-icon="payroll" class="nav-icon"></span><span>Payroll</span></button>
43
+ <button type="button" class="nav-item" data-not-simulated="Taxes"><span data-icon="taxes" class="nav-icon"></span><span>Taxes</span></button>
44
+ <button type="button" class="nav-item" data-not-simulated="Mileage"><span data-icon="mileage" class="nav-icon"></span><span>Mileage</span></button>
45
+ <button type="button" class="nav-item" data-not-simulated="My accountant"><span data-icon="accountant" class="nav-icon"></span><span>My accountant</span></button>
46
+ <button type="button" class="nav-item" data-not-simulated="Team"><span data-icon="team" class="nav-icon"></span><span>Team</span></button>
47
+ </nav>
48
+ <div class="nav-bottom">
49
+ <button type="button" class="nav-item" data-not-simulated="Customize"><span data-icon="customize" class="nav-icon"></span><span>Customize</span></button>
50
+ <button type="button" class="nav-item" id="nav-collapse" aria-label="Collapse the menu" aria-expanded="true" aria-controls="leftnav"><span data-icon="collapse" class="nav-icon"></span><span class="nav-collapse-label">Collapse the menu</span></button>
51
+ </div>
52
+ </aside>
53
+ <div class="frame">
54
+ <header class="topbar">
55
+ <button type="button" class="icon-btn topbar-menu" id="menu-toggle" aria-label="Open navigation" aria-expanded="false"><span data-icon="menu"></span></button>
56
+ <div class="company" id="company-name">Loading…</div>
57
+ <form class="gsearch" id="gsearch" role="search" autocomplete="off">
58
+ <span data-icon="search" class="gsearch-icon"></span>
59
+ <label class="visually-hidden" for="gsearch-input">Search</label>
60
+ <input id="gsearch-input" type="search" placeholder="Navigate or search for transactions, contacts, reports, and more" />
61
+ <div class="gsearch-results" id="gsearch-results" role="listbox" aria-label="Search results" hidden></div>
62
+ </form>
63
+ <div class="topbar-end">
64
+ <span class="sim-chip" title="All data here is synthetic and local to this Firedrill world">Firedrill test company</span>
65
+ <button type="button" class="icon-btn" data-not-simulated="My experts" aria-label="My experts" title="My experts"><span data-icon="experts"></span></button>
66
+ <button type="button" class="icon-btn" data-not-simulated="Help" aria-label="Help" title="Help"><span data-icon="help"></span></button>
67
+ <button type="button" class="icon-btn" data-not-simulated="Notifications" aria-label="Notifications" title="Notifications"><span data-icon="bell"></span></button>
68
+ <button type="button" class="icon-btn" data-not-simulated="Settings" aria-label="Settings" title="Settings"><span data-icon="gear"></span></button>
69
+ <button type="button" class="avatar" id="avatar" data-not-simulated="Account and profile" aria-label="Account and profile" title="Account and profile">·</button>
70
+ </div>
71
+ </header>
72
+ <main class="page" id="page" tabindex="-1" aria-live="polite">
73
+ <div class="loading"><span class="spinner" aria-hidden="true"></span><span>Loading…</span></div>
74
+ </main>
75
+ </div>
76
+ </div>
77
+ <div class="menu-pop" id="create-menu" role="menu" hidden></div>
78
+ <div class="toast" id="toast" role="status" hidden></div>
79
+ <div id="overlay-root"></div>
80
+ </body>
81
+ </html>
@@ -0,0 +1,99 @@
1
+ /* Shell: left navigation, top bar, menus. */
2
+ .shell { display: flex; height: 100vh; overflow: hidden; }
3
+ .leftnav {
4
+ width: 232px; flex: none; background: #fff; border-right: 1px solid var(--border);
5
+ display: flex; flex-direction: column; overflow: hidden;
6
+ }
7
+ /* The brand, Create button and bottom row stay put; only the menu itself scrolls. */
8
+ .brand, .create-wrap, .nav-bottom { flex: none; }
9
+ .brand { height: 64px; display: flex; align-items: center; padding: 0 20px; }
10
+ .brand-lockup { height: 28px; width: auto; }
11
+ .create-wrap { padding: 4px 16px 12px; }
12
+ .create-btn {
13
+ display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 18px 0 12px; border-radius: 20px;
14
+ border: 1px solid var(--border-strong); background: #fff; font-weight: 600; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.08);
15
+ }
16
+ .create-btn:hover, .create-btn[aria-expanded="true"] { background: var(--hover); }
17
+ .create-btn svg.i { color: var(--green); width: 22px; height: 22px; }
18
+ .navlist { display: flex; flex-direction: column; padding: 0 8px 8px; gap: 1px; flex: 1 1 auto; min-height: 0; overflow-y: auto; }
19
+ .nav-item {
20
+ display: flex; align-items: center; gap: 12px; height: 40px; padding: 0 12px; border-radius: 4px; border: 0;
21
+ background: transparent; color: var(--text); font-weight: 500; text-align: left; cursor: pointer; width: 100%; text-decoration: none;
22
+ }
23
+ .nav-item:hover { background: var(--hover); text-decoration: none; }
24
+ .nav-item.active { background: var(--green-tint); font-weight: 700; color: #000; }
25
+ .nav-item.active .nav-icon { color: var(--green-dark); }
26
+ .nav-icon { color: var(--text-2); display: inline-flex; }
27
+ .nav-heading { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .04em; padding: 16px 12px 6px; border-top: 1px solid var(--border); margin-top: 8px; }
28
+ .nav-sub { display: none; flex-direction: column; padding: 2px 0 6px 44px; }
29
+ .nav-group.open .nav-sub { display: flex; }
30
+ .nav-subitem { height: 32px; display: flex; align-items: center; padding: 0 10px; border-radius: 4px; color: var(--text); }
31
+ .nav-subitem:hover { background: var(--hover); text-decoration: none; }
32
+ .nav-subitem.active { color: var(--green-dark); font-weight: 700; background: var(--green-tint); }
33
+ .nav-bottom { padding: 8px; border-top: 1px solid var(--border); }
34
+ .brand-icon { height: 28px; width: 28px; display: none; }
35
+
36
+ /* Collapsed icon rail (the real client's "Collapse the menu" control). */
37
+ .shell.nav-collapsed .leftnav { width: 64px; }
38
+ .shell.nav-collapsed .brand { padding: 0 18px; }
39
+ .shell.nav-collapsed .brand-lockup { display: none; }
40
+ .shell.nav-collapsed .brand-icon { display: block; }
41
+ .shell.nav-collapsed .create-wrap { padding: 4px 12px 12px; }
42
+ .shell.nav-collapsed .create-btn { width: 40px; padding: 0; justify-content: center; border-radius: 50%; }
43
+ .shell.nav-collapsed .create-btn > span:not([data-icon]) { display: none; }
44
+ .shell.nav-collapsed .nav-item > span:not(.nav-icon) { display: none; }
45
+ .shell.nav-collapsed .nav-item { justify-content: center; padding: 0; }
46
+ .shell.nav-collapsed .nav-sub { display: none; }
47
+ .shell.nav-collapsed .nav-heading { font-size: 0; padding: 10px 12px 6px; }
48
+ .shell.nav-collapsed #nav-collapse svg.i { transform: scaleX(-1); }
49
+
50
+ .frame { flex: 1; min-width: 0; display: flex; flex-direction: column; }
51
+ .topbar {
52
+ height: 64px; flex: none; background: #fff; border-bottom: 1px solid var(--border);
53
+ display: flex; align-items: center; gap: 16px; padding: 0 16px 0 24px;
54
+ }
55
+ .topbar-menu { display: none; }
56
+ .company { font-weight: 700; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
57
+ .gsearch { position: relative; flex: 1; max-width: 560px; margin: 0 auto; }
58
+ .gsearch input {
59
+ width: 100%; height: 40px; border-radius: 20px; border: 1px solid var(--border-strong); background: #fff;
60
+ padding: 0 16px 0 40px; outline: none;
61
+ }
62
+ .gsearch input:focus { border-color: var(--green); box-shadow: 0 0 0 2px var(--green-tint); }
63
+ .gsearch-icon { position: absolute; left: 12px; top: 10px; color: var(--text-2); }
64
+ .gsearch-results { position: absolute; top: 44px; left: 0; right: 0; background: #fff; border-radius: 4px; box-shadow: var(--shadow-lg); z-index: 50; max-height: 420px; overflow-y: auto; padding: 6px 0; }
65
+ .gsearch-results .res { display: flex; justify-content: space-between; gap: 12px; padding: 8px 16px; cursor: pointer; color: var(--text); }
66
+ .gsearch-results .res:hover, .gsearch-results .res.focus { background: var(--hover); text-decoration: none; }
67
+ .gsearch-results .res-kind { color: var(--text-2); font-size: 12px; }
68
+ .gsearch-results .res-head { font-size: 12px; font-weight: 700; color: var(--text-2); padding: 8px 16px 4px; text-transform: uppercase; }
69
+ .topbar-end { display: flex; align-items: center; gap: 4px; }
70
+ .sim-chip { font-size: 12px; color: var(--text-2); border: 1px dashed var(--border-strong); border-radius: 12px; padding: 2px 10px; margin-right: 8px; white-space: nowrap; }
71
+ .avatar { width: 36px; height: 36px; border-radius: 50%; border: 0; background: #0077c5; color: #fff; font-weight: 700; cursor: pointer; margin-left: 4px; }
72
+ .page { flex: 1; overflow-y: auto; outline: none; }
73
+
74
+ .menu-pop { position: fixed; z-index: 60; background: #fff; border-radius: 4px; box-shadow: var(--shadow-lg); padding: 8px 0; min-width: 200px; }
75
+ .menu-pop .mi { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 8px 16px; cursor: pointer; color: var(--text); white-space: nowrap; }
76
+ .menu-pop .mi:hover:not(:disabled), .menu-pop .mi:focus { background: var(--hover); outline: none; }
77
+ .menu-pop .mi:disabled { color: var(--text-3); cursor: not-allowed; }
78
+ .menu-pop.create-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 0 16px; padding: 16px 20px; }
79
+ .create-col h3 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; color: var(--text-2); letter-spacing: .04em; padding: 0 8px; }
80
+ .create-col .mi { padding: 6px 8px; border-radius: 4px; }
81
+
82
+ @media (max-width: 900px) {
83
+ .leftnav { position: fixed; inset: 0 auto 0 0; z-index: 70; transform: translateX(-100%); transition: transform .2s; box-shadow: var(--shadow-lg); }
84
+ .shell.nav-open .leftnav { transform: none; }
85
+ .topbar-menu { display: inline-flex; }
86
+ .sim-chip, .company { display: none; }
87
+ .topbar { padding: 0 8px; gap: 8px; }
88
+ .menu-pop.create-grid { grid-template-columns: 1fr 1fr; }
89
+ /* The drawer is always full width on small screens; the rail is a desktop control. */
90
+ .shell.nav-collapsed .leftnav { width: 232px; }
91
+ .shell.nav-collapsed .brand-lockup, .shell.nav-collapsed .nav-item > span, .shell.nav-collapsed .nav-group.open .nav-sub { display: flex; }
92
+ .shell.nav-collapsed .brand-lockup { display: block; }
93
+ .shell.nav-collapsed .brand-icon { display: none; }
94
+ .shell.nav-collapsed .nav-item { justify-content: flex-start; padding: 0 12px; }
95
+ .shell.nav-collapsed .create-btn { width: auto; padding: 0 18px 0 12px; border-radius: 20px; justify-content: flex-start; }
96
+ .shell.nav-collapsed .create-btn > span { display: inline-flex; }
97
+ .shell.nav-collapsed .nav-heading { font-size: 12px; padding: 16px 12px 6px; }
98
+ #nav-collapse { display: none; }
99
+ }
@@ -0,0 +1,85 @@
1
+ // Shared app state: company + world clock, query helpers compiled to query.run, invoice status and routing.
2
+ import { getContext } from "/_firedrill/client.js";
3
+ import { call, dayNum } from "./ui.js";
4
+
5
+ export const app = { pages: Object.create(null), company: null, today: "", time: "", context: null, dirty: false };
6
+
7
+ export async function loadCompany() {
8
+ const [context, info] = await Promise.all([getContext(), call("company-info.get", {})]);
9
+ app.context = context;
10
+ app.company = info.CompanyInfo;
11
+ setClock(info.time);
12
+ return info;
13
+ }
14
+ /** "2026-09-15T02:00:00-07:00" → today in the company's time zone (world virtual time, never the browser clock). */
15
+ export function setClock(time) {
16
+ if (typeof time === "string" && /^\d{4}-\d{2}-\d{2}/.test(time)) { app.time = time; app.today = time.slice(0, 10); }
17
+ }
18
+
19
+ export const PAGE = 1000;
20
+ /** Run one query and return the entity array (and keep the clock fresh). */
21
+ export async function query(text) {
22
+ const out = await call("query.run", { query: text });
23
+ setClock(out.time);
24
+ const r = out.QueryResponse ?? {};
25
+ const key = Object.keys(r).find((k) => Array.isArray(r[k]));
26
+ return key ? r[key] : [];
27
+ }
28
+ export async function count(text) {
29
+ const out = await call("query.run", { query: text });
30
+ setClock(out.time);
31
+ return out.QueryResponse?.totalCount ?? 0;
32
+ }
33
+ /**
34
+ * Every row of a query, paged with STARTPOSITION/MAXRESULTS. The service caps a page at 1000 rows and about 900 KB of JSON
35
+ * and cuts a page before the first row that does not fit, so a short page is NOT the last page: paging continues from
36
+ * STARTPOSITION + rows returned until a page comes back empty. A page that fails STATE_BOUND_EXCEEDED (a single row over
37
+ * the bound) is retried with a halved page size down to 1, then thrown. Never truncates: past maxRows it throws.
38
+ */
39
+ export async function queryAll(text, maxRows = 20000) {
40
+ const rows = [];
41
+ let size = PAGE;
42
+ for (;;) {
43
+ let batch;
44
+ try {
45
+ batch = await query(`${text} STARTPOSITION ${rows.length + 1} MAXRESULTS ${size}`);
46
+ } catch (error) {
47
+ if (size > 1 && typeof error?.is === "function" && error.is("STATE_BOUND_EXCEEDED")) { size = Math.max(1, Math.floor(size / 2)); continue; }
48
+ throw error;
49
+ }
50
+ if (batch.length === 0) return rows;
51
+ rows.push(...batch);
52
+ if (rows.length >= maxRows) throw new Error(`More than ${maxRows} rows; narrow the filter.`);
53
+ }
54
+ }
55
+
56
+ export function isVoided(inv) {
57
+ return Number(inv.TotalAmt) === 0 && typeof inv.PrivateNote === "string" && inv.PrivateNote.startsWith("Voided");
58
+ }
59
+ /** QuickBooks invoice status label + tone, computed against the company's today. */
60
+ export function invoiceStatus(inv, today = app.today) {
61
+ if (isVoided(inv)) return { key: "voided", label: "Voided", tone: "grey" };
62
+ const balance = Number(inv.Balance);
63
+ const total = Number(inv.TotalAmt);
64
+ if (balance <= 0) {
65
+ const deposited = inv.depositedTo === "bank";
66
+ return deposited ? { key: "deposited", label: "Deposited", tone: "green" } : { key: "paid", label: "Paid", tone: "green" };
67
+ }
68
+ const days = dayNum(inv.DueDate) - dayNum(today);
69
+ const partial = balance < total ? `Partially paid, ${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(balance)} due` : "";
70
+ if (days < 0) return { key: "overdue", label: `Overdue ${-days} day${days === -1 ? "" : "s"}`, sub: partial, tone: "orange" };
71
+ if (days === 0) return { key: "due", label: "Due today", sub: partial, tone: "orange" };
72
+ return { key: "open", label: `Due in ${days} day${days === 1 ? "" : "s"}`, sub: partial, tone: "grey", sent: inv.EmailStatus === "EmailSent" };
73
+ }
74
+
75
+ // ---- routing ----
76
+ export function parseRoute(hash = location.hash) {
77
+ const [path, qs = ""] = hash.replace(/^#\/?/, "").split("?");
78
+ const parts = path.split("/").filter(Boolean).map((p) => { try { return decodeURIComponent(p); } catch { return ""; } });
79
+ return { page: parts[0] || "home", id: parts[1], sub: parts[2], params: new URLSearchParams(qs) };
80
+ }
81
+ export function go(hash) {
82
+ if (location.hash === hash) window.dispatchEvent(new HashChangeEvent("hashchange"));
83
+ else location.hash = hash;
84
+ }
85
+ export function register(name, render) { app.pages[name] = render; }
@@ -0,0 +1,114 @@
1
+ /* Page headers, tabs, money bar, data tables, status pills, pagination. */
2
+ .page-inner { padding: 20px 32px 48px; max-width: 1480px; }
3
+ .page-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; margin: 4px 0 12px; }
4
+ .page-head h1 { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -.01em; }
5
+ .page-head .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
6
+ .crumb { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
7
+ .tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--border); margin: 0 0 20px; overflow-x: auto; }
8
+ .tab { padding: 10px 0; color: var(--text-2); font-weight: 600; border-bottom: 4px solid transparent; white-space: nowrap; background: none; border-top: 0; border-left: 0; border-right: 0; cursor: pointer; }
9
+ .tab:hover { color: var(--text); text-decoration: none; }
10
+ .tab.active { color: var(--text); border-bottom-color: var(--green); }
11
+ .card { background: #fff; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
12
+ .muted { color: var(--text-2); }
13
+
14
+ .moneybar { display: grid; grid-template-columns: 3fr 2fr; gap: 3px; background: #fff; padding: 16px 20px 18px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 20px; }
15
+ .mb-group-head { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-2); margin-bottom: 8px; padding-right: 12px; }
16
+ .mb-group-head strong { color: var(--text); font-size: 13px; font-weight: 700; }
17
+ .mb-segs { display: flex; gap: 3px; }
18
+ .mb-seg { flex: 1; min-width: 88px; background: none; border: 0; padding: 0; text-align: left; cursor: pointer; }
19
+ .mb-seg .mb-amt { font-size: 20px; font-weight: 600; color: var(--text); }
20
+ .mb-seg .mb-lbl { font-size: 13px; color: var(--text-2); text-transform: uppercase; letter-spacing: .02em; }
21
+ .mb-seg .mb-bar { height: 18px; margin-top: 6px; border-radius: 1px; }
22
+ .mb-seg.selected .mb-bar { box-shadow: inset 0 -4px 0 rgba(0,0,0,.25); }
23
+ .mb-seg:hover .mb-amt { color: #000; }
24
+ .tone-orange { background: var(--orange-deep); }
25
+ .tone-grey { background: var(--blue-grey); }
26
+ .tone-lgreen { background: #7fcc6f; }
27
+ .tone-green { background: var(--green); }
28
+
29
+ .toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--border); }
30
+ .toolbar .grow { flex: 1; }
31
+ .tsearch { position: relative; }
32
+ .tsearch input { height: 36px; width: 260px; border: 1px solid var(--border-strong); border-radius: 4px; padding: 0 10px 0 34px; }
33
+ .tsearch svg.i { position: absolute; left: 9px; top: 9px; width: 18px; height: 18px; color: var(--text-2); }
34
+ .chip-select { height: 36px; border: 1px solid var(--border-strong); border-radius: 4px; padding: 0 8px; background: #fff; }
35
+ .filter-label { font-size: 12px; color: var(--text-2); display: flex; flex-direction: column; gap: 2px; }
36
+
37
+ .table-wrap { overflow-x: auto; }
38
+ table.grid { width: 100%; border-collapse: collapse; background: #fff; }
39
+ table.grid th {
40
+ text-align: left; font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--text-2); letter-spacing: .02em;
41
+ padding: 10px 12px; border-bottom: 1px solid var(--border); background: #fff; white-space: nowrap; position: sticky; top: 0;
42
+ }
43
+ table.grid td { padding: 12px; border-bottom: 1px solid #e3e5e8; vertical-align: middle; }
44
+ table.grid tbody tr:hover td { background: #f4f5f8; }
45
+ table.grid tbody tr.clickable { cursor: pointer; }
46
+ table.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
47
+ table.grid .act { text-align: right; white-space: nowrap; }
48
+ table.grid .check { width: 36px; }
49
+ table.grid td.empty { text-align: center; padding: 56px 12px; color: var(--text-2); }
50
+ .empty-title { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
51
+ .row-actions { display: inline-flex; align-items: center; gap: 2px; }
52
+ .row-actions .link-btn { padding: 0 6px; }
53
+ .row-actions .caret { background: none; border: 0; color: var(--link); cursor: pointer; padding: 2px; display: inline-flex; border-left: 1px solid var(--border); }
54
+ .row-actions .caret svg.i { width: 16px; height: 16px; }
55
+
56
+ .status { display: flex; align-items: flex-start; gap: 6px; }
57
+ .status .dot { width: 14px; height: 14px; border-radius: 50%; margin-top: 3px; flex: none; }
58
+ .status .dot.orange { background: var(--orange-deep); }
59
+ .status .dot.green { background: var(--green); }
60
+ .status .dot.grey { background: #8d9096; }
61
+ .status .dot.hollow { background: #fff; border: 2px solid #8d9096; }
62
+ .status .s-sub { font-size: 12px; color: var(--text-2); }
63
+ .status .s-main.orange { color: #c35300; font-weight: 600; }
64
+ .pill { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 10px; background: var(--hover); color: var(--text-2); }
65
+ .pill.inactive { background: #fdf1f0; color: var(--red); }
66
+
67
+ .pager { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 12px 16px; color: var(--text-2); font-size: 13px; }
68
+ .pager .icon-btn { width: 32px; height: 32px; border-radius: 4px; }
69
+ .pager .icon-btn:disabled { opacity: .4; cursor: not-allowed; }
70
+
71
+ /* Home */
72
+ .home-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 20px; }
73
+ .widget { padding: 20px; }
74
+ .widget h2 { font-size: 16px; font-weight: 700; margin: 0 0 4px; display: flex; justify-content: space-between; }
75
+ .widget .big { font-size: 28px; font-weight: 600; margin: 8px 0 2px; }
76
+ .hbar { display: flex; height: 24px; gap: 2px; margin: 10px 0 6px; border-radius: 2px; overflow: hidden; background: var(--hover); }
77
+ .hbar > div { min-width: 2px; }
78
+ .legend { display: flex; justify-content: space-between; font-size: 13px; }
79
+ .legend .l-amt { font-weight: 700; font-size: 15px; }
80
+ .shortcuts { display: flex; gap: 20px; flex-wrap: wrap; margin: 4px 0 24px; }
81
+ .shortcut { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 104px; background: none; border: 0; cursor: pointer; color: var(--text); font-size: 13px; text-align: center; }
82
+ .shortcut .sc-ico { width: 56px; height: 56px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.15); display: flex; align-items: center; justify-content: center; color: var(--green-dark); }
83
+ .shortcut:hover .sc-ico { background: var(--green-tint); }
84
+ .greeting { font-size: 24px; font-weight: 600; margin: 8px 0 16px; }
85
+ .alist { list-style: none; margin: 8px 0 0; padding: 0; }
86
+ .alist li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e3e5e8; }
87
+ @media (max-width: 900px) {
88
+ .page-inner { padding: 16px; max-width: 100%; }
89
+ .moneybar { grid-template-columns: 1fr; }
90
+ .tsearch input { width: 100%; }
91
+ .page-head { align-items: flex-start; }
92
+ .page-head h1 { font-size: 22px; }
93
+ .page-head .actions { width: 100%; }
94
+ .page-head .actions .btn { flex: 1 1 auto; }
95
+ .page-head .split { flex: 1 1 auto; }
96
+ .tabs { gap: 18px; }
97
+ .batch-bar { flex-wrap: wrap; gap: 8px 12px; }
98
+ .toolbar > select, .filter-label select { min-width: 0; width: 100%; }
99
+ .filter-label { flex: 1 1 140px; }
100
+ }
101
+ .filter-label select, .toolbar > select { height: 36px; min-width: 150px; border: 1px solid var(--border-strong); border-radius: 4px; padding: 0 8px; background: #fff; font-size: 14px; color: var(--text); }
102
+ .toolbar .checkline { font-size: 14px; }
103
+
104
+ /* Batch selection bar: shown above the grid while rows are checked. */
105
+ .batch-bar { display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--hover); }
106
+ .batch-bar[hidden] { display: none; }
107
+ .batch-count { font-weight: 700; font-size: 14px; color: var(--text); }
108
+ .batch-bar .grow { flex: 1; }
109
+ .batch-bar .btn .i { width: 16px; height: 16px; margin-left: 6px; vertical-align: middle; }
110
+ .batch-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 320px; overflow-y: auto; }
111
+ .batch-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid #e3e5e8; }
112
+ .batch-list .bl-note { font-size: 13px; color: var(--text-2); text-align: right; }
113
+ .batch-list .bl-note.err { color: var(--red); }
114
+ .batch-list .bl-note.ok { color: var(--green-dark); }
@@ -0,0 +1,189 @@
1
+ // Safe DOM helpers, operation calls, formatting, toasts, dialogs and menus. Record text only ever goes through textContent.
2
+ import { getContext, invoke } from "/_firedrill/client.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ export const $ = (sel, root = document) => root.querySelector(sel);
6
+ export function el(tag, attrs = {}, children = []) {
7
+ const node = document.createElement(tag);
8
+ for (const [k, v] of Object.entries(attrs)) {
9
+ if (v === undefined || v === null || v === false) continue;
10
+ if (k === "class") node.className = v;
11
+ else if (k === "text") node.textContent = String(v);
12
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
13
+ else if (k === "value") node.value = v;
14
+ else node.setAttribute(k, v === true ? "" : String(v));
15
+ }
16
+ for (const c of [].concat(children)) {
17
+ if (c === null || c === undefined || c === false) continue;
18
+ node.append(c instanceof Node ? c : document.createTextNode(String(c)));
19
+ }
20
+ return node;
21
+ }
22
+ export { icon };
23
+
24
+ export class ToolError extends Error {
25
+ constructor(status, code, message, details) {
26
+ super(message);
27
+ this.status = status;
28
+ this.code = String(code ?? status);
29
+ this.details = details ?? {};
30
+ }
31
+ get denied() { return this.status === "denied"; }
32
+ is(code) { return this.code === code || this.code === `tool.${code}` || this.code.endsWith(`.${code}`); }
33
+ get qboCode() { return this.details.qboCode; }
34
+ get element() { return typeof this.details.element === "string" ? this.details.element : ""; }
35
+ get detail() { return typeof this.details.detail === "string" ? this.details.detail : this.message; }
36
+ }
37
+
38
+ let pending = 0;
39
+ export const isPending = () => pending > 0;
40
+ export async function call(operationId, args = {}, idempotencyKey) {
41
+ pending += 1;
42
+ try {
43
+ const result = await invoke(operationId, args, idempotencyKey ? { idempotencyKey } : {});
44
+ if (result.outcome.status !== "ok") {
45
+ const e = result.outcome.error ?? {};
46
+ throw new ToolError(result.outcome.status, e.code ?? result.outcome.status, e.message ?? `The request was ${result.outcome.status}.`, e.details);
47
+ }
48
+ return result.outcome.value;
49
+ } finally {
50
+ pending -= 1;
51
+ }
52
+ }
53
+ export const newKey = () => crypto.randomUUID();
54
+
55
+ export function isAccessDenied(error) {
56
+ return error instanceof ToolError && (error.denied || error.is("AUTHORIZATION_FAILED") || error.is("AUTHENTICATION_FAILED"));
57
+ }
58
+ export function describe(error) {
59
+ if (!(error instanceof ToolError)) return error?.message ?? "Something went wrong. Try again.";
60
+ if (error.denied) return "This Firedrill actor is not granted this QuickBooks operation.";
61
+ if (error.is("THROTTLE_EXCEEDED")) return "QuickBooks is receiving too many requests right now. Wait a moment, then try again.";
62
+ if (error.status === "invalid") return `Invalid request: ${error.message}`;
63
+ return error.detail || error.message;
64
+ }
65
+
66
+ // ---- formatting (all dates are calendar strings from the company clock, never the browser clock) ----
67
+ const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
68
+ const plain = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
69
+ export const money = (n) => usd.format(Number(n) || 0);
70
+ export const amount = (n) => plain.format(Number(n) || 0);
71
+ export function mdY(date) {
72
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(date ?? ""));
73
+ return m ? `${m[2]}/${m[3]}/${m[1]}` : "";
74
+ }
75
+ export function dayNum(date) {
76
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(date ?? ""));
77
+ return m ? Math.floor(Date.UTC(+m[1], +m[2] - 1, +m[3]) / 86400000) : NaN;
78
+ }
79
+ export function addDays(date, days) {
80
+ const n = dayNum(date);
81
+ if (Number.isNaN(n)) return date;
82
+ return new Date((n + days) * 86400000).toISOString().slice(0, 10);
83
+ }
84
+ export const quote = (text) => `'${String(text).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
85
+
86
+ // ---- toast ----
87
+ let toastTimer;
88
+ export function toast(message, { error = false, action } = {}) {
89
+ const host = $("#toast");
90
+ const children = [el("span", { text: message })];
91
+ if (action) children.push(el("button", { type: "button", text: action.label, onclick: () => { host.hidden = true; action.run(); } }));
92
+ host.replaceChildren(...children);
93
+ host.className = `toast ${error ? "error" : "success"}`;
94
+ host.hidden = false;
95
+ clearTimeout(toastTimer);
96
+ toastTimer = setTimeout(() => { host.hidden = true; }, error ? 9000 : 4000);
97
+ }
98
+
99
+ export function banner(kind, title, detail, extra) {
100
+ return el("div", { class: `banner ${kind}`, role: kind === "error" ? "alert" : "status" }, [
101
+ icon(kind === "info" ? "info" : "alert"),
102
+ el("div", {}, [el("div", { class: "banner-title", text: title }), detail ? el("div", { class: "banner-detail", text: detail }) : null, extra ?? null]),
103
+ ]);
104
+ }
105
+ export function errorBanner(error, title = "Something's not right") {
106
+ if (isAccessDenied(error)) return banner("warn", "You don't have access to this", describe(error));
107
+ const code = error instanceof ToolError && error.qboCode ? ` (Error ${error.qboCode})` : "";
108
+ return banner("error", title + code, describe(error));
109
+ }
110
+ export function loadingRows(cols, rows = 6) {
111
+ return Array.from({ length: rows }, () => el("tr", {}, Array.from({ length: cols }, () => el("td", {}, el("div", { class: "skeleton" })))));
112
+ }
113
+
114
+ // ---- dialogs, menus, not-simulated panel ----
115
+ let dialogSeq = 0;
116
+ export function dialog({ title, body, actions = [], wide = false, onClose }) {
117
+ const root = $("#overlay-root");
118
+ const previous = document.activeElement;
119
+ const close = () => { wrap.remove(); document.removeEventListener("keydown", onKey); onClose?.(); previous?.focus?.(); };
120
+ const onKey = (e) => { if (e.key === "Escape") close(); };
121
+ const titleId = `dlg-title-${(dialogSeq += 1)}`;
122
+ const box = el("div", { class: `dialog${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }, [
123
+ el("div", { class: "dialog-head" }, [el("h2", { id: titleId, text: title }), el("button", { type: "button", class: "icon-btn", "aria-label": "Close", onclick: close }, icon("close"))]),
124
+ el("div", { class: "dialog-body" }, body),
125
+ actions.length ? el("div", { class: "dialog-foot" }, actions.map((a) => el("button", { type: "button", class: `btn ${a.kind ?? ""}`, text: a.label, onclick: () => a.run(close) }))) : null,
126
+ ]);
127
+ const wrap = el("div", { class: "scrim", onclick: (e) => { if (e.target === wrap) close(); } }, box);
128
+ root.append(wrap);
129
+ document.addEventListener("keydown", onKey);
130
+ (box.querySelector(".dialog-foot .btn") ?? box.querySelector("button"))?.focus();
131
+ return { close, box };
132
+ }
133
+ export function confirmDialog(title, message, confirmLabel, kind = "primary") {
134
+ return new Promise((resolve) => {
135
+ let answered = false;
136
+ dialog({
137
+ title, body: el("p", { text: message }),
138
+ onClose: () => { if (!answered) resolve(false); },
139
+ actions: [
140
+ { label: "Cancel", run: (close) => { answered = true; resolve(false); close(); } },
141
+ { label: confirmLabel, kind, run: (close) => { answered = true; resolve(true); close(); } },
142
+ ],
143
+ });
144
+ });
145
+ }
146
+ export function notSimulated(feature) {
147
+ dialog({
148
+ title: feature,
149
+ body: [el("p", { text: `${feature} is part of QuickBooks Online but is not simulated by this Firedrill Tool.` }),
150
+ el("p", { class: "muted", text: "This Tool models customers, products and services, invoices, payments, the chart of accounts and company info." })],
151
+ actions: [{ label: "OK", kind: "primary", run: (close) => close() }],
152
+ });
153
+ }
154
+ let openMenuState;
155
+ export function closeMenu() { openMenuState?.(); openMenuState = undefined; }
156
+ export function openMenu(anchor, items, { className = "" } = {}) {
157
+ closeMenu();
158
+ const pop = el("div", { class: `menu-pop ${className}`, role: "menu" });
159
+ for (const it of items) {
160
+ pop.append(el("button", { type: "button", class: "mi", role: "menuitem", text: it.label, disabled: it.disabled, title: it.title, onclick: () => { closeMenu(); it.run(); } }));
161
+ }
162
+ document.body.append(pop);
163
+ const r = anchor.getBoundingClientRect();
164
+ pop.style.top = `${Math.min(r.bottom + 4, window.innerHeight - pop.offsetHeight - 8)}px`;
165
+ pop.style.left = `${Math.max(8, Math.min(r.right - pop.offsetWidth, window.innerWidth - pop.offsetWidth - 8))}px`;
166
+ anchor.setAttribute("aria-expanded", "true");
167
+ const away = (e) => { if (!pop.contains(e.target) && e.target !== anchor) closeMenu(); };
168
+ const key = (e) => { if (e.key === "Escape") { closeMenu(); anchor.focus(); } };
169
+ setTimeout(() => document.addEventListener("click", away), 0);
170
+ document.addEventListener("keydown", key);
171
+ openMenuState = () => { pop.remove(); anchor.setAttribute("aria-expanded", "false"); document.removeEventListener("click", away); document.removeEventListener("keydown", key); };
172
+ pop.querySelector(".mi:not(:disabled)")?.focus();
173
+ return pop;
174
+ }
175
+
176
+ export function watchWorld(refresh, mayRefresh = () => true) {
177
+ let revision;
178
+ const check = async () => {
179
+ if (pending > 0 || document.visibilityState !== "visible") return;
180
+ try {
181
+ const context = await getContext();
182
+ const stamp = JSON.stringify(context.revision);
183
+ if (revision === undefined) { revision = stamp; return; }
184
+ if (stamp !== revision && mayRefresh()) { revision = stamp; await refresh(); }
185
+ } catch { /* environment closed; keep the page as is */ }
186
+ };
187
+ setInterval(check, 2000);
188
+ return check;
189
+ }
@@ -0,0 +1,59 @@
1
+ // Accounting > Chart of accounts (read-only in this Tool).
2
+ import { app, queryAll, register } from "../store.js";
3
+ import { amount, el, errorBanner, icon, isAccessDenied, loadingRows, notSimulated } from "../ui.js";
4
+ import { emptyRow, pageShell } from "./common.js";
5
+
6
+ const TYPES_ORDER = ["Bank", "Accounts Receivable", "Other Current Asset", "Fixed Asset", "Accounts Payable", "Credit Card", "Other Current Liability", "Equity", "Income", "Cost of Goods Sold", "Expense", "Other Expense"];
7
+
8
+ register("accounts", async (host, route) => {
9
+ const { root, body } = pageShell("Chart of accounts", {
10
+ crumb: "Accounting",
11
+ actions: [
12
+ el("button", { type: "button", class: "btn", text: "Run report", onclick: () => notSimulated("Account QuickReport") }),
13
+ el("button", { type: "button", class: "btn primary", text: "New", title: "Creating accounts is not simulated", onclick: () => notSimulated("New account") }),
14
+ ],
15
+ tabs: el("nav", { class: "tabs", "aria-label": "Accounting" }, [
16
+ el("a", { class: "tab active", href: "#/accounts", "aria-current": "page", text: "Chart of accounts" }),
17
+ el("button", { type: "button", class: "tab", text: "Reconcile", onclick: () => notSimulated("Reconcile") }),
18
+ ]),
19
+ });
20
+ const showInactive = route.params.get("inactive") === "1";
21
+ const toggle = el("input", { type: "checkbox", id: "coa-inactive", checked: showInactive, onchange: () => { location.hash = `#/accounts${toggle.checked ? "?inactive=1" : ""}`; } });
22
+ const tbody = el("tbody", {}, loadingRows(5));
23
+ const search = el("input", { type: "search", placeholder: "Search by name or number", "aria-label": "Search the chart of accounts" });
24
+ const card = el("div", { class: "card" }, [
25
+ el("div", { class: "toolbar" }, [
26
+ el("div", { class: "tsearch" }, [icon("search"), search]),
27
+ el("label", { class: "checkline", for: "coa-inactive" }, [toggle, "Include inactive"]),
28
+ el("div", { class: "grow" }),
29
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Print the chart of accounts", title: "Print", onclick: () => notSimulated("Print") }, icon("print")),
30
+ el("button", { type: "button", class: "icon-btn", "aria-label": "Table settings", title: "Settings", onclick: () => notSimulated("Table settings") }, icon("gear")),
31
+ ]),
32
+ el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [
33
+ el("thead", {}, el("tr", {}, ["Name", "Account type", "Detail type", "QuickBooks balance", "Action"].map((h, i) => el("th", { class: i === 3 ? "num" : i === 4 ? "act" : "", text: h })))),
34
+ tbody,
35
+ ])),
36
+ ]);
37
+ body.append(card);
38
+ host.replaceChildren(root);
39
+ try {
40
+ const all = await queryAll(`select * from Account${showInactive ? " where Active IN (true, false)" : ""}`);
41
+ all.sort((a, b) => (TYPES_ORDER.indexOf(a.AccountType) - TYPES_ORDER.indexOf(b.AccountType)) || a.Name.localeCompare(b.Name));
42
+ const draw = () => {
43
+ const needle = search.value.trim().toLowerCase();
44
+ const rows = needle ? all.filter((a) => `${a.Name} ${a.AcctNum ?? ""} ${a.AccountType}`.toLowerCase().includes(needle)) : all;
45
+ tbody.replaceChildren(...(rows.length ? rows.map((a) => el("tr", {}, [
46
+ el("td", {}, [el("span", { text: a.Name }), a.Active ? null : el("span", { class: "pill inactive", text: " (deleted)" })]),
47
+ el("td", { text: a.AccountType }),
48
+ el("td", { text: (a.AccountSubType ?? "").replace(/([a-z])([A-Z])/g, "$1 $2") }),
49
+ el("td", { class: "num", text: ["Bank", "Accounts Receivable", "Other Current Asset", "Credit Card", "Accounts Payable"].includes(a.AccountType) ? amount(a.CurrentBalance) : "" }),
50
+ el("td", { class: "act" }, el("button", { type: "button", class: "link-btn", text: a.AccountType === "Bank" ? "Account history" : "Run report", onclick: () => notSimulated("Account register") })),
51
+ ])) : [emptyRow(5, needle ? "No matching accounts" : "No accounts", needle ? "Try a different search." : "")]));
52
+ };
53
+ search.addEventListener("input", draw);
54
+ draw();
55
+ } catch (error) {
56
+ card.replaceWith(errorBanner(error, isAccessDenied(error) ? "You don't have access to the chart of accounts" : "We couldn't load your accounts"));
57
+ }
58
+ void app;
59
+ });