@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.
- package/LICENSE +201 -0
- package/README.md +240 -0
- package/firedrill/agent.target.json +17 -0
- package/firedrill/baseline.scenario.json +2338 -0
- package/firedrill/conformance.suite.json +22 -0
- package/firedrill/payment-response-lost.scenario.json +2344 -0
- package/firedrill/qbo-company-query.drill.json +143 -0
- package/firedrill/qbo-customers-items.drill.json +173 -0
- package/firedrill/qbo-default-identity.drill.json +53 -0
- package/firedrill/qbo-denied.drill.json +53 -0
- package/firedrill/qbo-escape-heavy-invoices.drill.json +160 -0
- package/firedrill/qbo-invalid-auth.drill.json +398 -0
- package/firedrill/qbo-invoice-lifecycle.drill.json +193 -0
- package/firedrill/qbo-large-invoice.drill.json +174 -0
- package/firedrill/qbo-mcp-shapes.drill.json +503 -0
- package/firedrill/qbo-payment-response-lost.drill.json +80 -0
- package/firedrill/qbo-payments.drill.json +110 -0
- package/firedrill/qbo-reports-only.drill.json +398 -0
- package/firedrill/qbo-roles.drill.json +143 -0
- package/firedrill/qbo-throttled.drill.json +398 -0
- package/firedrill/qbo-tight-limits.drill.json +368 -0
- package/firedrill/qbo-write-outage.drill.json +411 -0
- package/firedrill/throttled.scenario.json +2344 -0
- package/firedrill/tight-limits.scenario.json +2338 -0
- package/firedrill/tools/quickbooks/app/assets/ATTRIBUTION.md +33 -0
- package/firedrill/tools/quickbooks/app/assets/fonts/OFL.txt +93 -0
- package/firedrill/tools/quickbooks/app/assets/intuit-quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/assets/quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/app.js +157 -0
- package/firedrill/tools/quickbooks/app/site/assets/fonts/figtree-latin.woff2 +0 -0
- package/firedrill/tools/quickbooks/app/site/assets/intuit-quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/assets/quickbooks.svg +1 -0
- package/firedrill/tools/quickbooks/app/site/base.css +107 -0
- package/firedrill/tools/quickbooks/app/site/forms.css +85 -0
- package/firedrill/tools/quickbooks/app/site/icons.js +73 -0
- package/firedrill/tools/quickbooks/app/site/index.html +81 -0
- package/firedrill/tools/quickbooks/app/site/nav.css +99 -0
- package/firedrill/tools/quickbooks/app/site/store.js +85 -0
- package/firedrill/tools/quickbooks/app/site/tables.css +114 -0
- package/firedrill/tools/quickbooks/app/site/ui.js +189 -0
- package/firedrill/tools/quickbooks/app/site/views/accounts.js +59 -0
- package/firedrill/tools/quickbooks/app/site/views/batch-actions.js +116 -0
- package/firedrill/tools/quickbooks/app/site/views/batch.js +91 -0
- package/firedrill/tools/quickbooks/app/site/views/common.js +87 -0
- package/firedrill/tools/quickbooks/app/site/views/customer-detail.js +85 -0
- package/firedrill/tools/quickbooks/app/site/views/customer-drawer.js +71 -0
- package/firedrill/tools/quickbooks/app/site/views/customers.js +87 -0
- package/firedrill/tools/quickbooks/app/site/views/home.js +79 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-actions.js +109 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-editor.js +126 -0
- package/firedrill/tools/quickbooks/app/site/views/invoice-lines.js +92 -0
- package/firedrill/tools/quickbooks/app/site/views/invoices.js +118 -0
- package/firedrill/tools/quickbooks/app/site/views/items.js +105 -0
- package/firedrill/tools/quickbooks/app/site/views/payments.js +63 -0
- package/firedrill/tools/quickbooks/app/site/views/receive-payment.js +102 -0
- package/firedrill/tools/quickbooks/app/site/views/txn-frame.js +25 -0
- package/firedrill/tools/quickbooks/behavior.mjs +10 -0
- package/firedrill/tools/quickbooks/lib/access.mjs +167 -0
- package/firedrill/tools/quickbooks/lib/common.mjs +204 -0
- package/firedrill/tools/quickbooks/lib/criteria.mjs +107 -0
- package/firedrill/tools/quickbooks/lib/customers.mjs +140 -0
- package/firedrill/tools/quickbooks/lib/fields.mjs +157 -0
- package/firedrill/tools/quickbooks/lib/handlers-read.mjs +81 -0
- package/firedrill/tools/quickbooks/lib/handlers-write.mjs +95 -0
- package/firedrill/tools/quickbooks/lib/invoice-draft.mjs +165 -0
- package/firedrill/tools/quickbooks/lib/invoices.mjs +229 -0
- package/firedrill/tools/quickbooks/lib/items.mjs +117 -0
- package/firedrill/tools/quickbooks/lib/like.mjs +49 -0
- package/firedrill/tools/quickbooks/lib/payments.mjs +200 -0
- package/firedrill/tools/quickbooks/lib/pdf.mjs +96 -0
- package/firedrill/tools/quickbooks/lib/query-eval.mjs +178 -0
- package/firedrill/tools/quickbooks/lib/query-parse.mjs +229 -0
- package/firedrill/tools/quickbooks/lib/views.mjs +88 -0
- package/firedrill/tools/quickbooks/lib/wire.mjs +147 -0
- package/firedrill/tools/quickbooks/quickbooks.tool.json +11014 -0
- package/firedrill/world.json +559 -0
- package/firedrill/write-outage.scenario.json +2344 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +2337 -0
- package/test/conformance.mjs +674 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Batch action menus for the invoice, customer and product lists. Every simulated action runs one call per selected row.
|
|
2
|
+
import { call, confirmDialog, describe, dialog, el, newKey, notSimulated, toast } from "../ui.js";
|
|
3
|
+
import { runBatch } from "./batch.js";
|
|
4
|
+
|
|
5
|
+
function resultDialog(title, rows) {
|
|
6
|
+
const holder = el("div");
|
|
7
|
+
const { close } = dialog({ title, wide: true, body: holder, actions: [{ label: "Close", kind: "primary", run: (c) => c() }] });
|
|
8
|
+
void rows;
|
|
9
|
+
return { holder, close };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const invLabel = (inv) => `Invoice ${inv.DocNumber ?? inv.Id} · ${inv.CustomerRef?.name ?? ""}`;
|
|
13
|
+
|
|
14
|
+
async function batchPrint(rows) {
|
|
15
|
+
const { holder } = resultDialog(`Print or download ${rows.length} transaction${rows.length > 1 ? "s" : ""}`, rows);
|
|
16
|
+
await runBatch({
|
|
17
|
+
box: holder, rows, label: invLabel, describeError: describe,
|
|
18
|
+
run: async (inv) => {
|
|
19
|
+
const out = await call("invoices.pdf", { invoice_id: inv.Id });
|
|
20
|
+
const bytes = Uint8Array.from(atob(out.base64), (ch) => ch.charCodeAt(0));
|
|
21
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
|
|
22
|
+
return el("a", { href: url, download: `Invoice_${inv.DocNumber ?? inv.Id}.pdf`, text: `Download PDF (${Math.ceil(out.size_bytes / 1024)} KB)` });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function batchSend(rows, refresh) {
|
|
28
|
+
const ok = await confirmDialog(
|
|
29
|
+
`Send ${rows.length} transaction${rows.length > 1 ? "s" : ""}?`,
|
|
30
|
+
`QuickBooks will email ${rows.length} invoice${rows.length > 1 ? "s" : ""} to the address on each one. Firedrill records a synthetic send; no e-mail is delivered.`,
|
|
31
|
+
"Send", "primary",
|
|
32
|
+
);
|
|
33
|
+
if (!ok) return;
|
|
34
|
+
const { holder } = resultDialog(`Send ${rows.length} transaction${rows.length > 1 ? "s" : ""}`, rows);
|
|
35
|
+
const sent = await runBatch({
|
|
36
|
+
box: holder, rows, label: invLabel, describeError: describe,
|
|
37
|
+
run: async (inv) => { await call("invoices.send", { invoice_id: inv.Id }, newKey()); return `Sent to ${inv.BillEmail?.Address ?? "the customer"}`; },
|
|
38
|
+
});
|
|
39
|
+
if (sent) toast(`${sent} of ${rows.length} invoice${rows.length > 1 ? "s" : ""} sent`);
|
|
40
|
+
refresh?.();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function batchDelete(rows, refresh) {
|
|
44
|
+
const ok = await confirmDialog(
|
|
45
|
+
`Delete ${rows.length} transaction${rows.length > 1 ? "s" : ""}?`,
|
|
46
|
+
`Are you sure you want to delete ${rows.length} invoice${rows.length > 1 ? "s" : ""}? This can't be undone. Any payments applied to them are left unapplied.`,
|
|
47
|
+
"Yes, delete", "danger",
|
|
48
|
+
);
|
|
49
|
+
if (!ok) return;
|
|
50
|
+
const { holder } = resultDialog(`Delete ${rows.length} transaction${rows.length > 1 ? "s" : ""}`, rows);
|
|
51
|
+
const gone = await runBatch({
|
|
52
|
+
box: holder, rows, label: invLabel, describeError: describe,
|
|
53
|
+
run: async (inv) => { await call("invoices.post", { operation: "delete", body: { Id: inv.Id, SyncToken: inv.SyncToken } }, newKey()); return "Deleted"; },
|
|
54
|
+
});
|
|
55
|
+
if (gone) toast(`${gone} of ${rows.length} invoice${rows.length > 1 ? "s" : ""} deleted`);
|
|
56
|
+
refresh?.();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function invoiceBatchActions(rows, refresh) {
|
|
60
|
+
return [
|
|
61
|
+
{ label: `Print transactions (${rows.length})`, run: () => batchPrint(rows) },
|
|
62
|
+
{ label: `Send transactions (${rows.length})`, run: () => batchSend(rows, refresh) },
|
|
63
|
+
{ label: `Delete (${rows.length})`, run: () => batchDelete(rows, refresh) },
|
|
64
|
+
{ label: "Print packing slip", title: "Not simulated by this Tool", run: () => notSimulated("Packing slips") },
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function batchInactive(rows, refresh) {
|
|
69
|
+
const ok = await confirmDialog(
|
|
70
|
+
`Make ${rows.length} customer${rows.length > 1 ? "s" : ""} inactive?`,
|
|
71
|
+
"Inactive customers are hidden from lists but their past transactions stay in your books. A customer with an open balance can't be made inactive.",
|
|
72
|
+
"Yes, make inactive", "primary",
|
|
73
|
+
);
|
|
74
|
+
if (!ok) return;
|
|
75
|
+
const { holder } = resultDialog(`Make ${rows.length} customer${rows.length > 1 ? "s" : ""} inactive`, rows);
|
|
76
|
+
const done = await runBatch({
|
|
77
|
+
box: holder, rows, label: (c) => c.DisplayName, describeError: describe,
|
|
78
|
+
run: async (c) => { await call("customers.post", { body: { Id: c.Id, SyncToken: c.SyncToken, sparse: true, Active: false } }, newKey()); return "Made inactive"; },
|
|
79
|
+
});
|
|
80
|
+
if (done) toast(`${done} of ${rows.length} customer${rows.length > 1 ? "s" : ""} made inactive`);
|
|
81
|
+
refresh?.();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function customerBatchActions(rows, refresh) {
|
|
85
|
+
return [
|
|
86
|
+
{ label: `Make inactive (${rows.length})`, run: () => batchInactive(rows, refresh) },
|
|
87
|
+
{ label: "Email", title: "Not simulated by this Tool", run: () => notSimulated("Batch email") },
|
|
88
|
+
{ label: "Create statements", title: "Not simulated by this Tool", run: () => notSimulated("Statements") },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function setItemsActive(rows, active, refresh) {
|
|
93
|
+
const verb = active ? "active" : "inactive";
|
|
94
|
+
const ok = await confirmDialog(
|
|
95
|
+
`Make ${rows.length} product${rows.length > 1 ? "s" : ""} or service${rows.length > 1 ? "s" : ""} ${verb}?`,
|
|
96
|
+
active ? "They will appear again on invoices and in lists." : "Inactive products and services stay on past transactions but can't be added to new ones.",
|
|
97
|
+
`Yes, make ${verb}`, "primary",
|
|
98
|
+
);
|
|
99
|
+
if (!ok) return;
|
|
100
|
+
const { holder } = resultDialog(`Make ${rows.length} item${rows.length > 1 ? "s" : ""} ${verb}`, rows);
|
|
101
|
+
const done = await runBatch({
|
|
102
|
+
box: holder, rows, label: (it) => it.Name, describeError: describe,
|
|
103
|
+
run: async (it) => { await call("items.post", { body: { Id: it.Id, SyncToken: it.SyncToken, sparse: true, Name: it.Name, Type: it.Type, Active: active } }, newKey()); return `Now ${verb}`; },
|
|
104
|
+
});
|
|
105
|
+
if (done) toast(`${done} of ${rows.length} now ${verb}`);
|
|
106
|
+
refresh?.();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function itemBatchActions(rows, refresh) {
|
|
110
|
+
return [
|
|
111
|
+
{ label: `Make active (${rows.length})`, run: () => setItemsActive(rows, true, refresh) },
|
|
112
|
+
{ label: `Make inactive (${rows.length})`, run: () => setItemsActive(rows, false, refresh) },
|
|
113
|
+
{ label: "Reclassify", title: "Not simulated by this Tool", run: () => notSimulated("Reclassify") },
|
|
114
|
+
{ label: "Assign category", title: "Not simulated by this Tool", run: () => notSimulated("Categories") },
|
|
115
|
+
];
|
|
116
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Row selection and the "N selected / Batch actions" bar that QuickBooks shows above a list grid.
|
|
2
|
+
import { el, icon, openMenu } from "../ui.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the header checkbox, the per-row checkbox cells and the batch bar.
|
|
6
|
+
* `actionsFor(rows)` returns openMenu items for the currently selected rows.
|
|
7
|
+
*/
|
|
8
|
+
export function createSelection({ noun, actionsFor, labelFor }) {
|
|
9
|
+
const entries = new Map(); // id -> { row, box }
|
|
10
|
+
const count = el("span", { class: "batch-count" });
|
|
11
|
+
const head = el("input", { type: "checkbox", "aria-label": `Select all ${noun}`, disabled: true });
|
|
12
|
+
const menuBtn = el("button", { type: "button", class: "btn", "aria-haspopup": "menu", "aria-expanded": "false" }, [
|
|
13
|
+
el("span", { text: "Batch actions" }), icon("chevronDown"),
|
|
14
|
+
]);
|
|
15
|
+
const clearBtn = el("button", { type: "button", class: "link-btn", text: "Clear selection" });
|
|
16
|
+
const bar = el("div", { class: "batch-bar", role: "region", "aria-label": `Selected ${noun}`, hidden: true }, [
|
|
17
|
+
count, menuBtn, el("div", { class: "grow" }), clearBtn,
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const chosen = () => [...entries.values()].filter((e) => e.box.checked).map((e) => e.row);
|
|
21
|
+
function sync() {
|
|
22
|
+
const n = chosen().length;
|
|
23
|
+
const total = entries.size;
|
|
24
|
+
count.textContent = `${n} selected`;
|
|
25
|
+
bar.hidden = n === 0;
|
|
26
|
+
head.disabled = total === 0;
|
|
27
|
+
head.checked = total > 0 && n === total;
|
|
28
|
+
head.indeterminate = n > 0 && n < total;
|
|
29
|
+
head.title = total === 0 ? "" : head.checked ? `Clear all ${noun} on this page` : `Select all ${noun} on this page`;
|
|
30
|
+
}
|
|
31
|
+
head.addEventListener("change", () => {
|
|
32
|
+
for (const e of entries.values()) e.box.checked = head.checked;
|
|
33
|
+
sync();
|
|
34
|
+
});
|
|
35
|
+
clearBtn.addEventListener("click", () => {
|
|
36
|
+
for (const e of entries.values()) e.box.checked = false;
|
|
37
|
+
sync();
|
|
38
|
+
head.focus();
|
|
39
|
+
});
|
|
40
|
+
menuBtn.addEventListener("click", () => {
|
|
41
|
+
const rows = chosen();
|
|
42
|
+
if (rows.length === 0) return;
|
|
43
|
+
openMenu(menuBtn, actionsFor(rows));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
head,
|
|
48
|
+
bar,
|
|
49
|
+
/** Resets the bar for a freshly rendered page of rows. */
|
|
50
|
+
reset() { entries.clear(); sync(); },
|
|
51
|
+
/** Checkbox cell for one row. */
|
|
52
|
+
cell(row) {
|
|
53
|
+
const box = el("input", { type: "checkbox", "aria-label": `Select ${labelFor(row)}` });
|
|
54
|
+
box.addEventListener("click", (e) => e.stopPropagation());
|
|
55
|
+
box.addEventListener("change", sync);
|
|
56
|
+
entries.set(String(row.Id), { row, box });
|
|
57
|
+
sync();
|
|
58
|
+
return el("td", { class: "check" }, box);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Runs `run(row)` over the selected rows one at a time and shows a per-row result list.
|
|
65
|
+
* Returns the number that succeeded.
|
|
66
|
+
*/
|
|
67
|
+
export async function runBatch({ box, rows, label, run, describeError }) {
|
|
68
|
+
const items = new Map();
|
|
69
|
+
const list = el("ul", { class: "batch-list" }, rows.map((row) => {
|
|
70
|
+
const note = el("span", { class: "bl-note", text: "Waiting…" });
|
|
71
|
+
items.set(String(row.Id), note);
|
|
72
|
+
return el("li", {}, [el("span", { text: label(row) }), note]);
|
|
73
|
+
}));
|
|
74
|
+
box.replaceChildren(el("p", { text: `${rows.length} ${rows.length === 1 ? "transaction" : "transactions"} selected.` }), list);
|
|
75
|
+
let ok = 0;
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
const note = items.get(String(row.Id));
|
|
78
|
+
note.textContent = "Working…";
|
|
79
|
+
note.className = "bl-note";
|
|
80
|
+
try {
|
|
81
|
+
const result = (await run(row)) ?? "Done";
|
|
82
|
+
if (result instanceof Node) note.replaceChildren(result); else note.textContent = String(result);
|
|
83
|
+
note.className = "bl-note ok";
|
|
84
|
+
ok += 1;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
note.textContent = describeError(error);
|
|
87
|
+
note.className = "bl-note err";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return ok;
|
|
91
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared view pieces: page header, Sales tabs, invoice status cell, pager, field helpers.
|
|
2
|
+
import { invoiceStatus } from "../store.js";
|
|
3
|
+
import { el, icon, notSimulated } from "../ui.js";
|
|
4
|
+
|
|
5
|
+
export function pageShell(title, { actions = [], tabs = null, crumb = null } = {}) {
|
|
6
|
+
const body = el("div", { class: "page-body" });
|
|
7
|
+
const root = el("div", { class: "page-inner" }, [
|
|
8
|
+
crumb ? el("div", { class: "crumb" }, crumb) : null,
|
|
9
|
+
el("div", { class: "page-head" }, [el("h1", { text: title }), el("div", { class: "actions" }, actions)]),
|
|
10
|
+
tabs,
|
|
11
|
+
body,
|
|
12
|
+
]);
|
|
13
|
+
return { root, body };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SALES_TABS = [["Overview"], ["All sales"], ["Invoices", "#/invoices", "invoices"], ["Payments", "#/payments", "payments"], ["Customers", "#/customers", "customers"], ["Products & services", "#/items", "items"]];
|
|
17
|
+
export function salesTabs(active) {
|
|
18
|
+
return el("nav", { class: "tabs", "aria-label": "Sales & Get paid" }, SALES_TABS.map(([label, href, key]) =>
|
|
19
|
+
href
|
|
20
|
+
? el("a", { class: `tab${key === active ? " active" : ""}`, href, "aria-current": key === active ? "page" : undefined, text: label })
|
|
21
|
+
: el("button", { type: "button", class: "tab", text: label, title: "Not simulated by this Tool", onclick: () => notSimulated(label) })));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function statusCell(inv, today) {
|
|
25
|
+
const st = invoiceStatus(inv, today);
|
|
26
|
+
const dot = st.key === "overdue" || st.key === "due" ? "orange" : st.key === "voided" ? "grey" : st.key === "paid" || st.key === "deposited" ? "green" : "hollow";
|
|
27
|
+
return el("div", { class: "status" }, [
|
|
28
|
+
el("span", { class: `dot ${dot}`, "aria-hidden": "true" }),
|
|
29
|
+
el("div", {}, [
|
|
30
|
+
el("div", { class: `s-main${dot === "orange" ? " orange" : ""}`, text: st.label }),
|
|
31
|
+
st.sub ? el("div", { class: "s-sub", text: st.sub }) : null,
|
|
32
|
+
st.key === "open" || st.key === "overdue" || st.key === "due" ? el("div", { class: "s-sub", text: inv.EmailStatus === "EmailSent" ? "Sent" : "Not sent" }) : null,
|
|
33
|
+
]),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pager with first/prev/next and "1-25 of 60" text. */
|
|
38
|
+
export function pager({ start, size, total, onChange }) {
|
|
39
|
+
const end = Math.min(start + size - 1, total);
|
|
40
|
+
const btn = (name, label, target, disabled) => el("button", { type: "button", class: "icon-btn", "aria-label": label, title: label, disabled, onclick: () => onChange(target) }, icon(name));
|
|
41
|
+
return el("div", { class: "pager" }, [
|
|
42
|
+
el("span", { text: total === 0 ? "0 items" : `${start}-${end} of ${total}` }),
|
|
43
|
+
btn("chevronLeft", "Previous page", Math.max(1, start - size), start <= 1),
|
|
44
|
+
btn("chevronRight", "Next page", start + size, end >= total),
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let fieldSeq = 0;
|
|
49
|
+
/** Labelled input/select/textarea; returns { wrap, input, setError }. */
|
|
50
|
+
export function field(label, control, { hint } = {}) {
|
|
51
|
+
const id = `f${(fieldSeq += 1)}`;
|
|
52
|
+
control.id = id;
|
|
53
|
+
const err = el("div", { class: "err", role: "alert" });
|
|
54
|
+
const wrap = el("div", { class: "field" }, [el("label", { for: id, text: label }), control, hint ? el("div", { class: "muted", text: hint }) : null, err]);
|
|
55
|
+
const setError = (msg) => { wrap.classList.toggle("invalid", Boolean(msg)); err.textContent = msg ?? ""; };
|
|
56
|
+
return { wrap, input: control, setError };
|
|
57
|
+
}
|
|
58
|
+
export const input = (attrs = {}) => el("input", { type: "text", ...attrs });
|
|
59
|
+
export function select(options, value) {
|
|
60
|
+
const s = el("select");
|
|
61
|
+
for (const [v, label, disabled] of options) s.append(el("option", { value: v, text: label, disabled }));
|
|
62
|
+
if (value !== undefined) s.value = value;
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
export function emptyRow(cols, title, text) {
|
|
66
|
+
return el("tr", {}, el("td", { class: "empty", colspan: cols }, [el("div", { class: "empty-title", text: title }), text ? el("div", { text }) : null]));
|
|
67
|
+
}
|
|
68
|
+
/** Drawer with header, scrolling body and footer actions. */
|
|
69
|
+
export function drawer(title, body, actions) {
|
|
70
|
+
const root = document.getElementById("overlay-root");
|
|
71
|
+
const previous = document.activeElement;
|
|
72
|
+
const scrim = el("div", { class: "drawer-scrim" });
|
|
73
|
+
const close = () => { scrim.remove(); panel.remove(); document.removeEventListener("keydown", onKey); previous?.focus?.(); };
|
|
74
|
+
const onKey = (e) => { if (e.key === "Escape") close(); };
|
|
75
|
+
const foot = el("div", { class: "drawer-foot" });
|
|
76
|
+
const panel = el("aside", { class: "drawer", role: "dialog", "aria-modal": "true", "aria-label": title }, [
|
|
77
|
+
el("div", { class: "drawer-head" }, [el("h2", { text: title }), el("button", { type: "button", class: "icon-btn", "aria-label": "Close", onclick: close }, icon("close"))]),
|
|
78
|
+
el("div", { class: "drawer-body" }, body),
|
|
79
|
+
foot,
|
|
80
|
+
]);
|
|
81
|
+
for (const a of actions(close)) foot.append(a);
|
|
82
|
+
scrim.addEventListener("click", close);
|
|
83
|
+
root.append(scrim, panel);
|
|
84
|
+
document.addEventListener("keydown", onKey);
|
|
85
|
+
panel.querySelector("input, select, textarea")?.focus();
|
|
86
|
+
return { close, panel };
|
|
87
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Customer page: contact header, open/overdue totals, Transaction list and Customer details tabs.
|
|
2
|
+
import { app, go, isVoided, queryAll, register } from "../store.js";
|
|
3
|
+
import { call, describe, el, errorBanner, icon, isAccessDenied, loadingRows, mdY, money, newKey, notSimulated, openMenu, quote, toast, confirmDialog } from "../ui.js";
|
|
4
|
+
import { emptyRow, statusCell } from "./common.js";
|
|
5
|
+
import { openCustomerDrawer } from "./customer-drawer.js";
|
|
6
|
+
import { rowActions } from "./invoice-actions.js";
|
|
7
|
+
import { openInvoiceEditor } from "./invoice-editor.js";
|
|
8
|
+
import { openReceivePayment } from "./receive-payment.js";
|
|
9
|
+
|
|
10
|
+
register("customer", async (host, route) => {
|
|
11
|
+
const refresh = () => { if (host.isConnected) app.pages.customer(host, route); };
|
|
12
|
+
host.replaceChildren(el("div", { class: "loading" }, [el("span", { class: "spinner" }), el("span", { text: "Loading customer…" })]));
|
|
13
|
+
let c;
|
|
14
|
+
try {
|
|
15
|
+
c = (await call("customers.get", { id: route.id ?? "" })).Customer;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
host.replaceChildren(el("div", { class: "page-inner" }, [el("div", { class: "crumb" }, el("a", { href: "#/customers", text: "Customers" })), errorBanner(error, isAccessDenied(error) ? "You don't have access to this customer" : "We couldn't find this customer")]));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tab = route.sub === "details" ? "details" : "transactions";
|
|
21
|
+
const newTxn = el("button", { type: "button", class: "btn primary", "aria-haspopup": "menu" }, ["New transaction", icon("chevronDown")]);
|
|
22
|
+
newTxn.addEventListener("click", () => openMenu(newTxn, [
|
|
23
|
+
{ label: "Invoice", disabled: !c.Active, run: () => openInvoiceEditor(null, refresh, { customerId: c.Id }) },
|
|
24
|
+
{ label: "Payment", run: () => openReceivePayment({ customerId: c.Id, onSaved: refresh }) },
|
|
25
|
+
{ label: "Estimate", run: () => notSimulated("Estimate") }, { label: "Sales receipt", run: () => notSimulated("Sales receipt") },
|
|
26
|
+
{ label: "Credit memo", run: () => notSimulated("Credit memo") }, { label: "Statement", run: () => notSimulated("Statement") },
|
|
27
|
+
]));
|
|
28
|
+
const inactivate = async () => {
|
|
29
|
+
if (!(await confirmDialog("Make inactive?", `Are you sure you want to make ${c.DisplayName} inactive?`, "Yes, make inactive"))) return;
|
|
30
|
+
try { await call("customers.delete", { idOrEntity: { Id: c.Id, SyncToken: c.SyncToken } }, newKey()); toast(`${c.DisplayName} is now inactive`); refresh(); }
|
|
31
|
+
catch (error) { toast(describe(error), { error: true }); }
|
|
32
|
+
};
|
|
33
|
+
const reactivate = async () => {
|
|
34
|
+
try { await call("customers.post", { body: { Id: c.Id, SyncToken: c.SyncToken, sparse: true, Active: true } }, newKey()); toast(`${c.DisplayName} is active again`); refresh(); }
|
|
35
|
+
catch (error) { toast(describe(error), { error: true }); }
|
|
36
|
+
};
|
|
37
|
+
const addr = c.BillAddr ? [c.BillAddr.Line1, c.BillAddr.City && `${c.BillAddr.City}, ${c.BillAddr.CountrySubDivisionCode ?? ""} ${c.BillAddr.PostalCode ?? ""}`.trim()].filter(Boolean).join(", ") : "";
|
|
38
|
+
const overdueHost = el("div", { class: "l-amt", text: "…" });
|
|
39
|
+
const header = el("div", { class: "card widget" }, [
|
|
40
|
+
el("div", { class: "sheet-top" }, [
|
|
41
|
+
el("div", {}, [
|
|
42
|
+
el("h1", { class: "greeting", text: c.DisplayName }),
|
|
43
|
+
c.Active ? null : el("span", { class: "pill inactive", text: "Inactive" }),
|
|
44
|
+
el("div", { class: "muted", text: [c.CompanyName, c.PrimaryEmailAddr?.Address, c.PrimaryPhone?.FreeFormNumber].filter(Boolean).join(" · ") }),
|
|
45
|
+
addr ? el("div", { class: "muted", text: addr }) : null,
|
|
46
|
+
c.Notes ? el("div", { class: "muted", text: `Notes: ${c.Notes}` }) : null,
|
|
47
|
+
]),
|
|
48
|
+
el("div", { class: "actions", style: undefined }, [
|
|
49
|
+
el("div", { class: "page-head" }, el("div", { class: "actions" }, [el("button", { type: "button", class: "btn", text: "Edit", onclick: () => openCustomerDrawer(c, refresh) }),
|
|
50
|
+
c.Active ? el("button", { type: "button", class: "btn", text: "Make inactive", onclick: inactivate }) : el("button", { type: "button", class: "btn", text: "Make active", onclick: reactivate }), newTxn])),
|
|
51
|
+
el("div", { class: "legend" }, [el("div", {}, [el("div", { class: "l-amt", text: money(c.Balance) }), el("div", { class: "muted", text: "Open balance" })]), el("div", {}, [overdueHost, el("div", { class: "muted", text: "Overdue payment" })])]),
|
|
52
|
+
]),
|
|
53
|
+
]),
|
|
54
|
+
]);
|
|
55
|
+
const tabs = el("nav", { class: "tabs", "aria-label": "Customer" }, [
|
|
56
|
+
el("a", { class: `tab${tab === "transactions" ? " active" : ""}`, href: `#/customers/${encodeURIComponent(c.Id)}`, text: "Transaction list" }),
|
|
57
|
+
el("a", { class: `tab${tab === "details" ? " active" : ""}`, href: `#/customers/${encodeURIComponent(c.Id)}/details`, text: "Customer details" }),
|
|
58
|
+
el("button", { type: "button", class: "tab", text: "Statements", onclick: () => notSimulated("Statements") }),
|
|
59
|
+
]);
|
|
60
|
+
const content = el("div", { class: "card" });
|
|
61
|
+
host.replaceChildren(el("div", { class: "page-inner" }, [el("div", { class: "crumb" }, el("a", { href: "#/customers", text: "Customers" })), header, el("br"), tabs, content]));
|
|
62
|
+
|
|
63
|
+
if (tab === "details") {
|
|
64
|
+
const row = (k, v) => el("div", { class: "field" }, [el("span", { class: "muted", text: k }), el("div", { text: v || "—" })]);
|
|
65
|
+
content.replaceChildren(el("div", { class: "widget row2" }, [row("Customer display name", c.DisplayName), row("Company", c.CompanyName), row("First name", c.GivenName), row("Last name", c.FamilyName),
|
|
66
|
+
row("Email", c.PrimaryEmailAddr?.Address), row("Phone", c.PrimaryPhone?.FreeFormNumber), row("Billing address", addr), row("Terms", c.SalesTermRef?.name), row("Notes", c.Notes), row("Customer since", mdY(c.MetaData?.CreateTime))]));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const tbody = el("tbody", {}, loadingRows(8, 4));
|
|
70
|
+
content.replaceChildren(el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [el("thead", {}, el("tr", {}, ["Date", "Type", "No.", "Due date", "Balance", "Total", "Status", "Action"].map((h, i) => el("th", { class: i === 4 || i === 5 ? "num" : i === 7 ? "act" : "", text: h })))), tbody])));
|
|
71
|
+
try {
|
|
72
|
+
const ref = quote(c.Id);
|
|
73
|
+
const [invoices, payments] = await Promise.all([queryAll(`select * from Invoice where CustomerRef = ${ref}`), queryAll(`select * from Payment where CustomerRef = ${ref}`)]);
|
|
74
|
+
const overdue = invoices.filter((i) => Number(i.Balance) > 0 && i.DueDate < app.today).reduce((s, i) => s + Number(i.Balance), 0);
|
|
75
|
+
overdueHost.textContent = money(overdue);
|
|
76
|
+
const all = [...invoices.map((i) => ({ kind: "Invoice", date: i.TxnDate, row: i })), ...payments.map((p) => ({ kind: "Payment", date: p.TxnDate, row: p }))].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
|
77
|
+
tbody.replaceChildren(...(all.length ? all.map(({ kind, row }) => kind === "Invoice"
|
|
78
|
+
? el("tr", { class: "clickable", onclick: (e) => { if (!e.target.closest("button")) openInvoiceEditor(row.Id, refresh); } }, [el("td", { text: mdY(row.TxnDate) }), el("td", { text: "Invoice" }), el("td", { text: row.DocNumber ?? "" }), el("td", { text: mdY(row.DueDate) }), el("td", { class: "num", text: money(row.Balance) }), el("td", { class: "num", text: isVoided(row) ? "$0.00" : money(row.TotalAmt) }), el("td", {}, statusCell(row)), el("td", { class: "act" }, rowActions(row, refresh))])
|
|
79
|
+
: el("tr", {}, [el("td", { text: mdY(row.TxnDate) }), el("td", { text: "Payment" }), el("td", { text: row.PaymentRefNum ?? "" }), el("td", { text: "" }), el("td", { class: "num", text: money(-(row.UnappliedAmt ?? 0)) }), el("td", { class: "num", text: money(-row.TotalAmt) }), el("td", { text: String(row.PrivateNote ?? "").startsWith("Voided") ? "Voided" : Number(row.UnappliedAmt ?? 0) > 0 ? "Unapplied" : "Closed" }), el("td", { class: "act" }, el("a", { href: `#/payments?customer=${encodeURIComponent(c.Id)}`, text: "View" }))]))
|
|
80
|
+
: [emptyRow(8, "No transactions yet", "Invoices and payments for this customer appear here.")]));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
content.replaceChildren(errorBanner(error, "We couldn't load transactions"));
|
|
83
|
+
}
|
|
84
|
+
void go;
|
|
85
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// New / edit customer drawer (customers.post create or full update with SyncToken).
|
|
2
|
+
import { go } from "../store.js";
|
|
3
|
+
import { ToolError, call, describe, el, newKey, toast } from "../ui.js";
|
|
4
|
+
import { drawer, field, input } from "./common.js";
|
|
5
|
+
|
|
6
|
+
export function openCustomerDrawer(existing = null, onSaved) {
|
|
7
|
+
const c = existing ?? {};
|
|
8
|
+
const f = {
|
|
9
|
+
GivenName: field("First name", input({ value: c.GivenName ?? "", autocomplete: "off" })),
|
|
10
|
+
FamilyName: field("Last name", input({ value: c.FamilyName ?? "", autocomplete: "off" })),
|
|
11
|
+
CompanyName: field("Company name", input({ value: c.CompanyName ?? "" })),
|
|
12
|
+
DisplayName: field("Customer display name *", input({ value: c.DisplayName ?? "" })),
|
|
13
|
+
Email: field("Email", input({ type: "email", value: c.PrimaryEmailAddr?.Address ?? "" })),
|
|
14
|
+
Phone: field("Phone number", input({ type: "tel", value: c.PrimaryPhone?.FreeFormNumber ?? "" })),
|
|
15
|
+
Line1: field("Street address 1", input({ value: c.BillAddr?.Line1 ?? "" })),
|
|
16
|
+
City: field("City", input({ value: c.BillAddr?.City ?? "" })),
|
|
17
|
+
State: field("State", input({ value: c.BillAddr?.CountrySubDivisionCode ?? "" })),
|
|
18
|
+
Zip: field("ZIP code", input({ value: c.BillAddr?.PostalCode ?? "" })),
|
|
19
|
+
Notes: field("Notes", el("textarea", { value: c.Notes ?? "" })),
|
|
20
|
+
};
|
|
21
|
+
const display = f.DisplayName.input;
|
|
22
|
+
const suggest = () => {
|
|
23
|
+
if (display.dataset.touched) return;
|
|
24
|
+
display.value = f.CompanyName.input.value.trim() || `${f.GivenName.input.value} ${f.FamilyName.input.value}`.trim();
|
|
25
|
+
};
|
|
26
|
+
if (!existing) for (const k of ["GivenName", "FamilyName", "CompanyName"]) f[k].input.addEventListener("input", suggest);
|
|
27
|
+
display.addEventListener("input", () => { display.dataset.touched = "1"; });
|
|
28
|
+
const formError = el("div");
|
|
29
|
+
const body = [formError,
|
|
30
|
+
el("div", { class: "section-title", text: "Name and contact" }),
|
|
31
|
+
el("div", { class: "row2" }, [f.GivenName.wrap, f.FamilyName.wrap]), f.CompanyName.wrap, f.DisplayName.wrap,
|
|
32
|
+
el("div", { class: "row2" }, [f.Email.wrap, f.Phone.wrap]),
|
|
33
|
+
el("div", { class: "section-title", text: "Addresses" }), f.Line1.wrap,
|
|
34
|
+
el("div", { class: "row3" }, [f.City.wrap, f.State.wrap, f.Zip.wrap]),
|
|
35
|
+
el("div", { class: "section-title", text: "Notes and attachments" }), f.Notes.wrap];
|
|
36
|
+
const key = newKey();
|
|
37
|
+
drawer(existing ? "Customer information" : "New customer", body, (close) => {
|
|
38
|
+
const save = el("button", { type: "button", class: "btn primary", text: "Save" });
|
|
39
|
+
save.addEventListener("click", async () => {
|
|
40
|
+
for (const x of Object.values(f)) x.setError("");
|
|
41
|
+
formError.replaceChildren();
|
|
42
|
+
const v = (k) => f[k].input.value.trim();
|
|
43
|
+
if (!v("DisplayName")) { f.DisplayName.setError("Enter a customer display name."); display.focus(); return; }
|
|
44
|
+
const bodyOut = { DisplayName: v("DisplayName") };
|
|
45
|
+
const opt = (k, val) => { if (val) bodyOut[k] = val; };
|
|
46
|
+
opt("GivenName", v("GivenName")); opt("FamilyName", v("FamilyName")); opt("CompanyName", v("CompanyName")); opt("Notes", v("Notes"));
|
|
47
|
+
if (v("Email")) bodyOut.PrimaryEmailAddr = { Address: v("Email") };
|
|
48
|
+
if (v("Phone")) bodyOut.PrimaryPhone = { FreeFormNumber: v("Phone") };
|
|
49
|
+
if (v("Line1") || v("City") || v("State") || v("Zip")) {
|
|
50
|
+
bodyOut.BillAddr = {};
|
|
51
|
+
for (const [k, s] of [["Line1", "Line1"], ["City", "City"], ["CountrySubDivisionCode", "State"], ["PostalCode", "Zip"]]) if (v(s)) bodyOut.BillAddr[k] = v(s);
|
|
52
|
+
}
|
|
53
|
+
if (existing) { bodyOut.Id = existing.Id; bodyOut.SyncToken = existing.SyncToken; bodyOut.sparse = true; }
|
|
54
|
+
save.disabled = true;
|
|
55
|
+
try {
|
|
56
|
+
const out = await call("customers.post", { body: bodyOut }, key);
|
|
57
|
+
close();
|
|
58
|
+
toast(existing ? "Customer saved" : `${out.Customer?.DisplayName ?? "Customer"} added`);
|
|
59
|
+
if (onSaved) onSaved(out.Customer); else go(`#/customers/${encodeURIComponent(out.Customer.Id)}`);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
save.disabled = false;
|
|
62
|
+
const map = { DisplayName: "DisplayName", PrimaryEmailAddr: "Email", PrimaryPhone: "Phone", GivenName: "GivenName", FamilyName: "FamilyName", CompanyName: "CompanyName", Notes: "Notes" };
|
|
63
|
+
const target = error instanceof ToolError ? map[error.element] : undefined;
|
|
64
|
+
if (target) f[target].setError(describe(error));
|
|
65
|
+
else formError.replaceChildren(el("div", { class: "banner error", role: "alert" }, el("div", { text: describe(error) })));
|
|
66
|
+
if (error instanceof ToolError && error.is("STALE_OBJECT")) formError.replaceChildren(el("div", { class: "banner warn", role: "alert", text: "This customer was changed elsewhere. Close and reopen it to get the latest version." }));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return [el("button", { type: "button", class: "btn", text: "Cancel", onclick: close }), save];
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Sales & Get paid > Customers: money bar, search, include-inactive, paging, row actions.
|
|
2
|
+
import { app, go, query, register } from "../store.js";
|
|
3
|
+
import { call, el, errorBanner, icon, isAccessDenied, loadingRows, money, notSimulated, openMenu, quote } from "../ui.js";
|
|
4
|
+
import { emptyRow, pageShell, pager, salesTabs, select } from "./common.js";
|
|
5
|
+
import { openCustomerDrawer } from "./customer-drawer.js";
|
|
6
|
+
import { openInvoiceEditor } from "./invoice-editor.js";
|
|
7
|
+
import { moneyBarData } from "./invoices.js";
|
|
8
|
+
import { openReceivePayment } from "./receive-payment.js";
|
|
9
|
+
import { createSelection } from "./batch.js";
|
|
10
|
+
import { customerBatchActions } from "./batch-actions.js";
|
|
11
|
+
|
|
12
|
+
const SIZE = 25;
|
|
13
|
+
|
|
14
|
+
register("customers", async (host, route) => {
|
|
15
|
+
const p = route.params;
|
|
16
|
+
const q = p.get("q") ?? "";
|
|
17
|
+
const inactive = p.get("inactive") === "1";
|
|
18
|
+
const balance = p.get("balance") ?? "";
|
|
19
|
+
const start = Math.max(1, Number(p.get("start")) || 1);
|
|
20
|
+
const nav = (patch) => { const n = new URLSearchParams(p); for (const [k, v] of Object.entries(patch)) v ? n.set(k, v) : n.delete(k); go(`#/customers?${n}`); };
|
|
21
|
+
const refresh = () => { if (host.isConnected) app.pages.customers(host, route); };
|
|
22
|
+
const { root, body } = pageShell("Customers", {
|
|
23
|
+
crumb: "Sales & Get paid",
|
|
24
|
+
actions: [el("button", { type: "button", class: "btn", text: "Customer types", onclick: () => notSimulated("Customer types") }),
|
|
25
|
+
el("div", { class: "split" }, [el("button", { type: "button", class: "btn primary", text: "New customer", onclick: () => openCustomerDrawer() }),
|
|
26
|
+
el("button", { type: "button", class: "btn primary", "aria-label": "More new customer options", onclick: () => notSimulated("Import customers") }, icon("chevronDown"))])],
|
|
27
|
+
tabs: salesTabs("customers"),
|
|
28
|
+
});
|
|
29
|
+
const bar = el("div", { class: "moneybar" }, [el("div", { class: "skeleton" }), el("div", { class: "skeleton" })]);
|
|
30
|
+
const search = el("input", { type: "search", placeholder: "Search", "aria-label": "Search customers", value: q });
|
|
31
|
+
search.addEventListener("keydown", (e) => { if (e.key === "Enter") nav({ q: search.value.trim(), start: "" }); });
|
|
32
|
+
const bal = select([["", "All customers"], ["open", "With open balance"]], balance);
|
|
33
|
+
bal.setAttribute("aria-label", "Balance filter");
|
|
34
|
+
bal.addEventListener("change", () => nav({ balance: bal.value, start: "" }));
|
|
35
|
+
const inc = el("input", { type: "checkbox", id: "cust-inactive", checked: inactive, onchange: () => nav({ inactive: inc.checked ? "1" : "", start: "" }) });
|
|
36
|
+
const sel = createSelection({ noun: "customers", labelFor: (c) => c.DisplayName, actionsFor: (rows) => customerBatchActions(rows, refresh) });
|
|
37
|
+
const tbody = el("tbody", {}, loadingRows(5));
|
|
38
|
+
const foot = el("div");
|
|
39
|
+
const card = el("div", { class: "card" }, [
|
|
40
|
+
el("div", { class: "toolbar" }, [el("div", { class: "tsearch" }, [icon("search"), search]), bal, el("label", { class: "checkline", for: "cust-inactive" }, [inc, "Include inactive"]), el("div", { class: "grow" }),
|
|
41
|
+
el("button", { type: "button", class: "icon-btn", "aria-label": "Print list", title: "Print", onclick: () => notSimulated("Print list") }, icon("print")),
|
|
42
|
+
el("button", { type: "button", class: "icon-btn", "aria-label": "Export to Excel", title: "Export", onclick: () => notSimulated("Export to Excel") }, icon("export"))]),
|
|
43
|
+
sel.bar,
|
|
44
|
+
el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [el("thead", {}, el("tr", {}, [el("th", { class: "check" }, sel.head), ...["Name", "Company name", "Phone", "Open balance", "Action"].map((h, i) => el("th", { class: i === 3 ? "num" : i === 4 ? "act" : "", text: h }))])), tbody])),
|
|
45
|
+
foot,
|
|
46
|
+
]);
|
|
47
|
+
body.append(bar, card);
|
|
48
|
+
host.replaceChildren(root);
|
|
49
|
+
moneyBarData().then((d) => bar.replaceChildren(
|
|
50
|
+
el("div", {}, [el("div", { class: "mb-group-head" }, [el("strong", { text: "Unpaid" }), el("span", { text: "Last 365 days" })]), el("div", { class: "mb-segs" }, [
|
|
51
|
+
el("a", { class: "mb-seg", href: "#/invoices?status=overdue" }, [el("div", { class: "mb-amt", text: money(d.overdue) }), el("div", { class: "mb-lbl", text: `${d.overdueN} Overdue invoices` }), el("div", { class: "mb-bar tone-orange" })]),
|
|
52
|
+
el("a", { class: "mb-seg", href: "#/invoices?status=unpaid" }, [el("div", { class: "mb-amt", text: money(d.overdue + d.notDue) }), el("div", { class: "mb-lbl", text: `${d.overdueN + d.notDueN} Open invoices` }), el("div", { class: "mb-bar tone-grey" })])])]),
|
|
53
|
+
el("div", {}, [el("div", { class: "mb-group-head" }, [el("strong", { text: "Paid" }), el("span", { text: "Last 30 days" })]), el("div", { class: "mb-segs" }, [
|
|
54
|
+
el("a", { class: "mb-seg", href: "#/payments" }, [el("div", { class: "mb-amt", text: money(d.deposited + d.notDeposited) }), el("div", { class: "mb-lbl", text: `${d.depositedN + d.notDepositedN} Paid last 30 days` }), el("div", { class: "mb-bar tone-green" })])])]),
|
|
55
|
+
)).catch((error) => (isAccessDenied(error) ? bar.remove() : bar.replaceChildren(errorBanner(error, "We couldn't load the summary"))));
|
|
56
|
+
try {
|
|
57
|
+
const where = [inactive ? "Active IN (true, false)" : "Active = true"];
|
|
58
|
+
if (q) where.push(`DisplayName LIKE ${quote(`%${q}%`)}`);
|
|
59
|
+
if (balance === "open") where.push("Balance > '0'");
|
|
60
|
+
const clause = ` where ${where.join(" AND ")}`;
|
|
61
|
+
const total = (await call("query.run", { query: `select count(*) from Customer${clause}` })).QueryResponse.totalCount ?? 0;
|
|
62
|
+
const rows = await query(`select * from Customer${clause} orderby DisplayName STARTPOSITION ${start} MAXRESULTS ${SIZE}`);
|
|
63
|
+
sel.reset();
|
|
64
|
+
tbody.replaceChildren(...(rows.length ? rows.map((c) => {
|
|
65
|
+
const open = Number(c.Balance) > 0;
|
|
66
|
+
const primary = open
|
|
67
|
+
? el("button", { type: "button", class: "link-btn", text: "Receive payment", onclick: () => openReceivePayment({ customerId: c.Id, onSaved: refresh }) })
|
|
68
|
+
: el("button", { type: "button", class: "link-btn", text: "Create invoice", disabled: !c.Active, onclick: () => openInvoiceEditor(null, refresh, { customerId: c.Id }) });
|
|
69
|
+
const caret = el("button", { type: "button", class: "caret", "aria-label": `More actions for ${c.DisplayName}`, "aria-haspopup": "menu" }, icon("chevronDown"));
|
|
70
|
+
caret.addEventListener("click", () => openMenu(caret, [
|
|
71
|
+
{ label: "Create invoice", disabled: !c.Active, run: () => openInvoiceEditor(null, refresh, { customerId: c.Id }) },
|
|
72
|
+
{ label: "Receive payment", run: () => openReceivePayment({ customerId: c.Id, onSaved: refresh }) },
|
|
73
|
+
{ label: "Create sales receipt", run: () => notSimulated("Sales receipt") },
|
|
74
|
+
{ label: "Create estimate", run: () => notSimulated("Estimate") },
|
|
75
|
+
{ label: "Edit", run: () => openCustomerDrawer(c, refresh) },
|
|
76
|
+
]));
|
|
77
|
+
const nameLink = el("a", { href: `#/customers/${encodeURIComponent(c.Id)}`, text: c.DisplayName });
|
|
78
|
+
return el("tr", {}, [sel.cell(c),
|
|
79
|
+
el("td", {}, [nameLink, c.Active ? null : el("span", { class: "pill inactive", text: " Inactive" }), c.PrimaryEmailAddr ? el("div", { class: "s-sub muted", text: c.PrimaryEmailAddr.Address }) : null]),
|
|
80
|
+
el("td", { text: c.CompanyName ?? "" }), el("td", { text: c.PrimaryPhone?.FreeFormNumber ?? "" }), el("td", { class: "num", text: money(c.Balance) }),
|
|
81
|
+
el("td", { class: "act" }, el("div", { class: "row-actions" }, [primary, caret]))]);
|
|
82
|
+
}) : [emptyRow(6, q ? "No customers match your search" : "No customers yet", q ? "Try another name." : "Add your first customer to start invoicing.")]));
|
|
83
|
+
foot.replaceChildren(pager({ start, size: SIZE, total, onChange: (s) => nav({ start: String(s) }) }));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
card.replaceWith(errorBanner(error, isAccessDenied(error) ? "You don't have access to Sales data" : "We couldn't load your customers"));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Home: business at a glance (shortcuts, Invoices, Bank accounts, Sales, Customers owing money).
|
|
2
|
+
import { app, go, queryAll, register } from "../store.js";
|
|
3
|
+
import { addDays, el, errorBanner, icon, isAccessDenied, money, notSimulated, quote } from "../ui.js";
|
|
4
|
+
import { openCustomerDrawer } from "./customer-drawer.js";
|
|
5
|
+
import { openInvoiceEditor } from "./invoice-editor.js";
|
|
6
|
+
import { moneyBarData } from "./invoices.js";
|
|
7
|
+
import { openReceivePayment } from "./receive-payment.js";
|
|
8
|
+
|
|
9
|
+
function widget(title, sub) {
|
|
10
|
+
const content = el("div", {}, [el("div", { class: "skeleton" }), el("br"), el("div", { class: "skeleton" })]);
|
|
11
|
+
const card = el("section", { class: "card widget", "aria-label": title }, [el("h2", {}, [el("span", { text: title }), sub ? el("span", { class: "muted", text: sub }) : null]), content]);
|
|
12
|
+
return { card, content };
|
|
13
|
+
}
|
|
14
|
+
const failed = (content, error) => content.replaceChildren(isAccessDenied(error)
|
|
15
|
+
? el("div", { class: "muted" }, [el("div", { class: "empty-title", text: "You don't have access" }), el("div", { text: "Your user role can't see this data. Ask your company admin for access." })])
|
|
16
|
+
: errorBanner(error, "We couldn't load this"));
|
|
17
|
+
|
|
18
|
+
function hbar(parts) {
|
|
19
|
+
const total = parts.reduce((s, p) => s + p.amt, 0) || 1;
|
|
20
|
+
return el("div", { class: "hbar", "aria-hidden": "true" }, parts.map((p) => { const d = el("div", { class: p.tone }); d.style.flex = String(Math.max(p.amt / total, p.amt > 0 ? 0.02 : 0)); return d; }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
register("home", async (host) => {
|
|
24
|
+
const hour = Number((app.time || "T09").split("T")[1]?.slice(0, 2) ?? 9);
|
|
25
|
+
const greet = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";
|
|
26
|
+
const sc = (label, name, run) => el("button", { type: "button", class: "shortcut", onclick: run }, [el("span", { class: "sc-ico" }, icon(name)), el("span", { text: label })]);
|
|
27
|
+
const inv = widget("Invoices");
|
|
28
|
+
const bank = widget("Bank accounts", "As of today");
|
|
29
|
+
const sales = widget("Sales", "Last 30 days");
|
|
30
|
+
const owing = widget("Customers owing money");
|
|
31
|
+
const pl = widget("Profit & loss", "Last month");
|
|
32
|
+
pl.content.replaceChildren(el("p", { class: "muted", text: "Profit and loss reports are not simulated by this Tool." }), el("button", { type: "button", class: "link-btn", text: "Learn more", onclick: () => notSimulated("Profit & loss") }));
|
|
33
|
+
const expenses = widget("Expenses", "Last month");
|
|
34
|
+
expenses.content.replaceChildren(el("p", { class: "muted", text: "Expenses, bills and bank feeds are not simulated by this Tool." }), el("button", { type: "button", class: "link-btn", text: "Learn more", onclick: () => notSimulated("Expenses") }));
|
|
35
|
+
const root = el("div", { class: "page-inner" }, [
|
|
36
|
+
el("div", { class: "greeting", text: `${greet}, ${app.company?.CompanyName ?? ""}` }),
|
|
37
|
+
app.companyError ? errorBanner(app.companyError, "We couldn't load your company") : null,
|
|
38
|
+
el("div", { class: "shortcuts", role: "group", "aria-label": "Shortcuts" }, [
|
|
39
|
+
sc("Create invoice", "invoice", () => openInvoiceEditor()), sc("Receive payment", "payment", () => openReceivePayment()),
|
|
40
|
+
sc("Add customer", "customers", () => openCustomerDrawer()), sc("Record expense", "expenses", () => notSimulated("Expense")),
|
|
41
|
+
sc("Add bank deposit", "bank", () => notSimulated("Bank deposit")), sc("Create check", "edit", () => notSimulated("Check")),
|
|
42
|
+
]),
|
|
43
|
+
el("div", { class: "home-grid" }, [inv.card, bank.card, sales.card, owing.card, pl.card, expenses.card]),
|
|
44
|
+
]);
|
|
45
|
+
host.replaceChildren(root);
|
|
46
|
+
|
|
47
|
+
moneyBarData().then((d) => inv.content.replaceChildren(
|
|
48
|
+
el("div", { class: "legend" }, [el("span", {}, [el("strong", { text: `${money(d.overdue + d.notDue)} Unpaid` })]), el("span", { class: "muted", text: "Last 365 days" })]),
|
|
49
|
+
hbar([{ amt: d.overdue, tone: "tone-orange" }, { amt: d.notDue, tone: "tone-grey" }]),
|
|
50
|
+
el("div", { class: "legend" }, [
|
|
51
|
+
el("a", { href: "#/invoices?status=overdue" }, [el("div", { class: "l-amt", text: money(d.overdue) }), el("div", { class: "muted", text: "Overdue" })]),
|
|
52
|
+
el("a", { href: "#/invoices?status=open" }, [el("div", { class: "l-amt", text: money(d.notDue) }), el("div", { class: "muted", text: "Not due yet" })])]),
|
|
53
|
+
el("div", { class: "hr" }),
|
|
54
|
+
el("div", { class: "legend" }, [el("span", {}, [el("strong", { text: `${money(d.notDeposited + d.deposited)} Paid` })]), el("span", { class: "muted", text: "Last 30 days" })]),
|
|
55
|
+
hbar([{ amt: d.notDeposited, tone: "tone-lgreen" }, { amt: d.deposited, tone: "tone-green" }]),
|
|
56
|
+
el("div", { class: "legend" }, [
|
|
57
|
+
el("a", { href: "#/payments" }, [el("div", { class: "l-amt", text: money(d.notDeposited) }), el("div", { class: "muted", text: "Not deposited" })]),
|
|
58
|
+
el("a", { href: "#/payments" }, [el("div", { class: "l-amt", text: money(d.deposited) }), el("div", { class: "muted", text: "Deposited" })])]),
|
|
59
|
+
)).catch((e) => failed(inv.content, e));
|
|
60
|
+
|
|
61
|
+
queryAll("select * from Account where AccountType = 'Bank'").then((accts) => bank.content.replaceChildren(
|
|
62
|
+
accts.length ? el("ul", { class: "alist" }, accts.map((a) => el("li", {}, [el("span", { text: a.Name }), el("strong", { text: money(a.CurrentBalance) })])))
|
|
63
|
+
: el("p", { class: "muted", text: "No bank accounts." }),
|
|
64
|
+
el("button", { type: "button", class: "link-btn", text: "Go to registers", onclick: () => go("#/accounts") }),
|
|
65
|
+
)).catch((e) => failed(bank.content, e));
|
|
66
|
+
|
|
67
|
+
queryAll(`select * from Invoice where TxnDate >= ${quote(addDays(app.today, -30))}`).then((rows) => {
|
|
68
|
+
const total = rows.reduce((s, r) => s + Number(r.TotalAmt || 0), 0);
|
|
69
|
+
sales.content.replaceChildren(el("div", { class: "big", text: money(total) }), el("div", { class: "muted", text: `${rows.length} invoice${rows.length === 1 ? "" : "s"} created in the last 30 days` }),
|
|
70
|
+
el("a", { href: "#/invoices?dates=30", text: "View invoices" }));
|
|
71
|
+
}).catch((e) => failed(sales.content, e));
|
|
72
|
+
|
|
73
|
+
queryAll("select * from Customer where Balance > '0' orderby DisplayName").then((rows) => {
|
|
74
|
+
rows.sort((a, b) => Number(b.Balance) - Number(a.Balance));
|
|
75
|
+
owing.content.replaceChildren(rows.length
|
|
76
|
+
? el("ul", { class: "alist" }, rows.slice(0, 6).map((c) => el("li", {}, [el("a", { href: `#/customers/${encodeURIComponent(c.Id)}`, text: c.DisplayName }), el("strong", { text: money(c.Balance) })])))
|
|
77
|
+
: el("p", { class: "muted", text: "No customers owe you money right now." }));
|
|
78
|
+
}).catch((e) => failed(owing.content, e));
|
|
79
|
+
});
|