@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,109 @@
|
|
|
1
|
+
// Invoice row actions: Receive payment / Send, and the row menu (Edit, Print or download, Send, Void, Delete).
|
|
2
|
+
import { app, isVoided } from "../store.js";
|
|
3
|
+
import { ToolError, amount, call, confirmDialog, describe, dialog, el, icon, mdY, money, newKey, openMenu, toast } from "../ui.js";
|
|
4
|
+
import { field, input } from "./common.js";
|
|
5
|
+
import { openInvoiceEditor } from "./invoice-editor.js";
|
|
6
|
+
import { openReceivePayment } from "./receive-payment.js";
|
|
7
|
+
|
|
8
|
+
async function retryable(run, label) {
|
|
9
|
+
try { return await run(); } catch (error) {
|
|
10
|
+
if (error instanceof ToolError && error.is("STALE_OBJECT")) toast("This invoice was changed elsewhere. Refresh and try again.", { error: true });
|
|
11
|
+
else toast(`${label}: ${describe(error)}`, { error: true, action: error instanceof ToolError && error.is("THROTTLE_EXCEEDED") ? { label: "Retry", run } : undefined });
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sendDialog(inv, onDone) {
|
|
17
|
+
const to = field("To", input({ type: "email", value: inv.BillEmail?.Address ?? "" }));
|
|
18
|
+
const subject = `Invoice ${inv.DocNumber ?? inv.Id} from ${app.company?.CompanyName ?? "your company"}`;
|
|
19
|
+
const key = newKey();
|
|
20
|
+
const { box } = dialog({
|
|
21
|
+
title: `Send invoice ${inv.DocNumber ?? ""}`.trim(), wide: false,
|
|
22
|
+
body: [to.wrap,
|
|
23
|
+
el("div", { class: "field" }, [el("span", { class: "muted", text: "Subject" }), el("div", { text: subject })]),
|
|
24
|
+
el("div", { class: "field" }, [el("span", { class: "muted", text: "Message" }), el("div", { text: `Dear ${inv.CustomerRef?.name ?? "customer"}, here's your invoice! We appreciate your prompt payment. Balance due ${money(inv.Balance)}, due ${mdY(inv.DueDate)}.` })]),
|
|
25
|
+
el("p", { class: "muted", text: "Firedrill records a synthetic send. No e-mail is delivered." })],
|
|
26
|
+
actions: [
|
|
27
|
+
{ label: "Cancel", run: (close) => close() },
|
|
28
|
+
{ label: "Send invoice", kind: "primary", run: async (close) => {
|
|
29
|
+
const btn = box.querySelector(".dialog-foot .primary");
|
|
30
|
+
btn.disabled = true;
|
|
31
|
+
to.setError("");
|
|
32
|
+
const address = to.input.value.trim();
|
|
33
|
+
try {
|
|
34
|
+
await call("invoices.send", { invoice_id: inv.Id, ...(address && address !== inv.BillEmail?.Address ? { sendTo: address } : address ? { sendTo: address } : {}) }, key);
|
|
35
|
+
close();
|
|
36
|
+
toast(`Invoice ${inv.DocNumber ?? ""} sent to ${address || "the customer"}`);
|
|
37
|
+
onDone?.();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
btn.disabled = false;
|
|
40
|
+
to.setError(describe(error));
|
|
41
|
+
}
|
|
42
|
+
} },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function voidInvoice(inv, refresh) {
|
|
48
|
+
const linked = (inv.LinkedTxn ?? []).length;
|
|
49
|
+
const msg = linked ? `Invoice ${inv.DocNumber} has ${linked} payment${linked > 1 ? "s" : ""} applied. Voiding sets its amount to zero and leaves those payments unapplied. Are you sure?` : `Are you sure you want to void invoice ${inv.DocNumber}? Its amount will be set to zero.`;
|
|
50
|
+
if (!(await confirmDialog("Void invoice?", msg, "Yes, void", "primary"))) return;
|
|
51
|
+
const done = await retryable(() => call("invoices.post", { operation: "void", body: { Id: inv.Id, SyncToken: inv.SyncToken } }, newKey()), "Couldn't void the invoice");
|
|
52
|
+
if (done) { toast(`Invoice ${inv.DocNumber} voided`); refresh?.(); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function deleteInvoice(inv, refresh) {
|
|
56
|
+
const linked = (inv.LinkedTxn ?? []).length;
|
|
57
|
+
const msg = linked ? `This invoice has payments applied. Deleting it leaves those payments unapplied. Delete invoice ${inv.DocNumber}?` : `Are you sure you want to delete invoice ${inv.DocNumber}? This can't be undone.`;
|
|
58
|
+
if (!(await confirmDialog("Delete invoice?", msg, "Yes, delete", "danger"))) return;
|
|
59
|
+
const done = await retryable(() => call("invoices.post", { operation: "delete", body: { Id: inv.Id, SyncToken: inv.SyncToken } }, newKey()), "Couldn't delete the invoice");
|
|
60
|
+
if (done) { toast(`Invoice ${inv.DocNumber} deleted`); refresh?.(); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function pdfPreview(inv) {
|
|
64
|
+
const link = el("a", { class: "btn primary", text: "Preparing PDF…", "aria-disabled": "true" });
|
|
65
|
+
const lines = (inv.Line ?? []).filter((l) => l.DetailType === "SalesItemLineDetail");
|
|
66
|
+
const sheet = el("div", { class: "pdf-frame" }, [
|
|
67
|
+
el("div", { class: "sheet-top" }, [
|
|
68
|
+
el("div", { class: "sheet-company" }, [el("div", { class: "co-name", text: app.company?.CompanyName ?? "" }), el("div", { text: app.company?.CompanyAddr?.Line1 ?? "" }), el("div", { text: app.company?.Email?.Address ?? "" })]),
|
|
69
|
+
el("div", { class: "sheet-balance" }, [el("div", { class: "lbl", text: "Invoice" }), el("div", { text: `No. ${inv.DocNumber ?? inv.Id}` }), el("div", { text: `Due ${mdY(inv.DueDate)}` })]),
|
|
70
|
+
]),
|
|
71
|
+
el("div", { class: "hr" }),
|
|
72
|
+
el("div", { text: `Bill to: ${inv.CustomerRef?.name ?? ""}` }),
|
|
73
|
+
el("table", { class: "lines" }, [el("thead", {}, el("tr", {}, ["Product or service", "Qty", "Rate", "Amount"].map((h, i) => el("th", { class: i ? "amt" : "", text: h })))),
|
|
74
|
+
el("tbody", {}, lines.map((l) => el("tr", {}, [el("td", { text: l.SalesItemLineDetail?.ItemRef?.name ?? "" }), el("td", { class: "amt", text: String(l.SalesItemLineDetail?.Qty ?? "") }), el("td", { class: "amt", text: amount(l.SalesItemLineDetail?.UnitPrice) }), el("td", { class: "amt", text: amount(l.Amount) })])))]),
|
|
75
|
+
el("div", { class: "totals" }, [el("div", { class: "t big" }, [el("span", { text: "Balance due" }), el("span", { text: money(inv.Balance) })])]),
|
|
76
|
+
]);
|
|
77
|
+
const { close } = dialog({ title: `Print or download invoice ${inv.DocNumber ?? ""}`, wide: true, body: [sheet, el("div", { class: "line-actions" }, link)] });
|
|
78
|
+
try {
|
|
79
|
+
const out = await call("invoices.pdf", { invoice_id: inv.Id });
|
|
80
|
+
const bytes = Uint8Array.from(atob(out.base64), (ch) => ch.charCodeAt(0));
|
|
81
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
|
|
82
|
+
link.textContent = `Download PDF (${Math.ceil(out.size_bytes / 1024)} KB)`;
|
|
83
|
+
link.setAttribute("href", url);
|
|
84
|
+
link.setAttribute("download", `Invoice_${inv.DocNumber ?? inv.Id}.pdf`);
|
|
85
|
+
link.removeAttribute("aria-disabled");
|
|
86
|
+
} catch (error) {
|
|
87
|
+
link.replaceWith(el("div", { class: "banner error", role: "alert", text: describe(error) }));
|
|
88
|
+
}
|
|
89
|
+
void close;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function rowActions(inv, refresh) {
|
|
93
|
+
const open = Number(inv.Balance) > 0 && !isVoided(inv);
|
|
94
|
+
const primary = open
|
|
95
|
+
? el("button", { type: "button", class: "link-btn", text: "Receive payment", onclick: (e) => { e.stopPropagation(); openReceivePayment({ customerId: inv.CustomerRef?.value, invoiceId: inv.Id, onSaved: refresh }); } })
|
|
96
|
+
: el("button", { type: "button", class: "link-btn", text: "Print", onclick: (e) => { e.stopPropagation(); pdfPreview(inv); } });
|
|
97
|
+
const caret = el("button", { type: "button", class: "caret", "aria-label": `More actions for invoice ${inv.DocNumber ?? inv.Id}`, "aria-haspopup": "menu", "aria-expanded": "false" }, icon("chevronDown"));
|
|
98
|
+
caret.addEventListener("click", (e) => {
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
openMenu(caret, [
|
|
101
|
+
{ label: "Edit", run: () => openInvoiceEditor(inv.Id, refresh) },
|
|
102
|
+
{ label: "Send", disabled: isVoided(inv), run: () => sendDialog(inv, refresh) },
|
|
103
|
+
{ label: "Print or download", run: () => pdfPreview(inv) },
|
|
104
|
+
{ label: "Void", disabled: isVoided(inv), run: () => voidInvoice(inv, refresh) },
|
|
105
|
+
{ label: "Delete", run: () => deleteInvoice(inv, refresh) },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
return el("div", { class: "row-actions" }, [primary, caret]);
|
|
109
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Invoice form (new / edit): customer, terms, dates, lines, memos, totals; Save / Save and send via invoices.post.
|
|
2
|
+
import { app, isVoided, queryAll } from "../store.js";
|
|
3
|
+
import { ToolError, addDays, call, confirmDialog, describe, el, errorBanner, money, newKey, toast } from "../ui.js";
|
|
4
|
+
import { field, input, select } from "./common.js";
|
|
5
|
+
import { deleteInvoice, pdfPreview, sendDialog, voidInvoice } from "./invoice-actions.js";
|
|
6
|
+
import { linesTable } from "./invoice-lines.js";
|
|
7
|
+
import { txnFrame } from "./txn-frame.js";
|
|
8
|
+
|
|
9
|
+
const TERMS = [["", "Select terms"], ["1", "Due on receipt", 0], ["2", "Net 15", 15], ["3", "Net 30", 30]];
|
|
10
|
+
|
|
11
|
+
export async function openInvoiceEditor(invoiceId = null, onSaved, { customerId = "" } = {}) {
|
|
12
|
+
let touched = false;
|
|
13
|
+
const t = txnFrame(invoiceId ? "Invoice" : "Invoice", {
|
|
14
|
+
onClose: async (close) => { if (!touched || (await confirmDialog("Leave without saving?", "Do you want to leave without saving? Your changes will be lost.", "Yes, leave"))) close(); },
|
|
15
|
+
});
|
|
16
|
+
t.canvas.append(el("div", { class: "loading" }, [el("span", { class: "spinner" }), el("span", { text: "Loading invoice…" })]));
|
|
17
|
+
let inv = null; let customers; let items;
|
|
18
|
+
try {
|
|
19
|
+
[inv, customers, items] = await Promise.all([
|
|
20
|
+
invoiceId ? call("invoices.get", { invoice_id: invoiceId }).then((o) => o.Invoice) : null,
|
|
21
|
+
queryAll("select * from Customer where Active IN (true, false) orderby DisplayName"),
|
|
22
|
+
queryAll("select * from Item orderby Name"),
|
|
23
|
+
]);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
t.canvas.replaceChildren(errorBanner(error, "We couldn't open this invoice"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const voided = inv ? isVoided(inv) : false;
|
|
29
|
+
t.setTitle(inv ? `Invoice no.${inv.DocNumber ?? inv.Id}` : "Invoice");
|
|
30
|
+
const custOptions = [["", "Add customer"], ...customers.filter((c) => c.Active || c.Id === inv?.CustomerRef?.value).map((c) => [c.Id, c.DisplayName])];
|
|
31
|
+
const cust = field("Customer", select(custOptions, inv?.CustomerRef?.value ?? customerId));
|
|
32
|
+
const email = field("Customer email", input({ type: "email", value: inv?.BillEmail?.Address ?? "", placeholder: "Separate emails with a comma" }));
|
|
33
|
+
const terms = field("Terms", select(TERMS.map(([v, l]) => [v, l]), inv?.SalesTermRef?.value ?? ""));
|
|
34
|
+
const txnDate = field("Invoice date", input({ type: "date", value: inv?.TxnDate ?? app.today }));
|
|
35
|
+
const dueDate = field("Due date", input({ type: "date", value: inv?.DueDate ?? addDays(app.today, 30) }));
|
|
36
|
+
const docNo = field("Invoice no.", input({ value: inv?.DocNumber ?? "", placeholder: "Auto" }));
|
|
37
|
+
const message = field("Message on invoice", el("textarea", { value: inv?.CustomerMemo?.value ?? "", placeholder: "Thank you for your business and have a great day!" }));
|
|
38
|
+
const memo = field("Internal customer notes (hidden)", el("textarea", { value: inv?.PrivateNote ?? "" }));
|
|
39
|
+
const bigBalance = el("div", { class: "val" });
|
|
40
|
+
const subtotal = el("span"); const total = el("span"); const paidRow = el("span"); const balance = el("span");
|
|
41
|
+
const paidCents = inv ? Math.round((Number(inv.TotalAmt) - Number(inv.Balance)) * 100) : 0;
|
|
42
|
+
let lines = null;
|
|
43
|
+
const refreshTotals = () => {
|
|
44
|
+
if (!lines) return;
|
|
45
|
+
const c = lines.totalCents();
|
|
46
|
+
subtotal.textContent = money(c / 100); total.textContent = money(c / 100); paidRow.textContent = money(-paidCents / 100);
|
|
47
|
+
const bal = voided ? 0 : Math.max(0, c - paidCents) / 100;
|
|
48
|
+
balance.textContent = money(bal); bigBalance.textContent = money(bal);
|
|
49
|
+
};
|
|
50
|
+
lines = linesTable(items.filter((i) => i.Active), (inv?.Line ?? []).filter((l) => l.DetailType === "SalesItemLineDetail"), () => refreshTotals());
|
|
51
|
+
const applyTerms = () => { const d = TERMS.find((x) => x[0] === terms.input.value)?.[2]; if (d !== undefined && txnDate.input.value) dueDate.input.value = addDays(txnDate.input.value, d); };
|
|
52
|
+
cust.input.addEventListener("change", () => {
|
|
53
|
+
const c = customers.find((x) => x.Id === cust.input.value);
|
|
54
|
+
if (c) { email.input.value = c.PrimaryEmailAddr?.Address ?? ""; if (c.SalesTermRef?.value && TERMS.some((x) => x[0] === c.SalesTermRef.value)) { terms.input.value = c.SalesTermRef.value; applyTerms(); } }
|
|
55
|
+
});
|
|
56
|
+
terms.input.addEventListener("change", applyTerms);
|
|
57
|
+
txnDate.input.addEventListener("change", applyTerms);
|
|
58
|
+
const formError = el("div");
|
|
59
|
+
const co = app.company ?? {};
|
|
60
|
+
t.canvas.replaceChildren(el("div", { class: "sheet" }, [
|
|
61
|
+
voided ? errorBanner(new Error("This invoice has been voided. Its amounts are zero and it can't be edited."), "Voided") : null,
|
|
62
|
+
formError,
|
|
63
|
+
el("div", { class: "sheet-top" }, [
|
|
64
|
+
el("div", { class: "sheet-company" }, [el("div", { class: "co-name", text: co.CompanyName ?? "" }), el("div", { text: co.CompanyAddr ? `${co.CompanyAddr.Line1 ?? ""}, ${co.CompanyAddr.City ?? ""}, ${co.CompanyAddr.CountrySubDivisionCode ?? ""} ${co.CompanyAddr.PostalCode ?? ""}` : "" }), el("div", { text: co.Email?.Address ?? "" })]),
|
|
65
|
+
el("div", { class: "sheet-balance" }, [el("div", { class: "lbl", text: "Balance due" }), bigBalance]),
|
|
66
|
+
]),
|
|
67
|
+
el("div", { class: "hr" }),
|
|
68
|
+
el("div", { class: "sheet-grid" }, [el("div", {}, [cust.wrap, email.wrap]), el("div", {}, [terms.wrap]), el("div", { class: "meta" }, [txnDate.wrap, dueDate.wrap, docNo.wrap])]),
|
|
69
|
+
lines.node,
|
|
70
|
+
el("div", { class: "sheet-bottom" }, [
|
|
71
|
+
el("div", { class: "notes" }, [message.wrap, memo.wrap]),
|
|
72
|
+
el("div", { class: "totals" }, [el("div", { class: "t" }, [el("span", { text: "Subtotal" }), subtotal]), el("div", { class: "t" }, [el("span", { text: "Total" }), total]),
|
|
73
|
+
inv ? el("div", { class: "t" }, [el("span", { text: "Amount received" }), paidRow]) : null, el("div", { class: "t big" }, [el("span", { text: "Balance due" }), balance])]),
|
|
74
|
+
]),
|
|
75
|
+
]));
|
|
76
|
+
refreshTotals();
|
|
77
|
+
t.canvas.addEventListener("input", () => { touched = true; });
|
|
78
|
+
t.canvas.addEventListener("change", () => { touched = true; });
|
|
79
|
+
t.canvas.addEventListener("click", (e) => { if (e.target.closest(".line-actions, .lines button")) touched = true; });
|
|
80
|
+
if (voided) for (const x of t.canvas.querySelectorAll("input, select, textarea, .line-actions button, .lines button")) x.disabled = true;
|
|
81
|
+
|
|
82
|
+
let key = newKey();
|
|
83
|
+
async function save(andSend, allowDuplicate = false) {
|
|
84
|
+
formError.replaceChildren();
|
|
85
|
+
for (const f of [cust, email, txnDate, dueDate, docNo]) f.setError("");
|
|
86
|
+
if (!cust.input.value) { cust.setError("Select a customer."); cust.input.focus(); return; }
|
|
87
|
+
const collected = lines.collect();
|
|
88
|
+
if (collected.error) { formError.replaceChildren(errorBanner(new Error(collected.error), "Check the product and service lines")); collected.focus?.focus(); return; }
|
|
89
|
+
const body = { CustomerRef: { value: cust.input.value }, TxnDate: txnDate.input.value, DueDate: dueDate.input.value, Line: collected.lines,
|
|
90
|
+
CustomerMemo: message.input.value.trim() ? { value: message.input.value.trim() } : null, PrivateNote: memo.input.value.trim() || null };
|
|
91
|
+
if (terms.input.value) body.SalesTermRef = { value: terms.input.value };
|
|
92
|
+
if (email.input.value.trim()) body.BillEmail = { Address: email.input.value.trim() };
|
|
93
|
+
if (docNo.input.value.trim()) body.DocNumber = docNo.input.value.trim();
|
|
94
|
+
if (inv) Object.assign(body, { Id: inv.Id, SyncToken: inv.SyncToken, sparse: true });
|
|
95
|
+
for (const b of t.foot.querySelectorAll("button")) b.disabled = true;
|
|
96
|
+
try {
|
|
97
|
+
const out = await call("invoices.post", { body, ...(allowDuplicate ? { include: "allowduplicatedocnum" } : {}) }, key);
|
|
98
|
+
key = newKey();
|
|
99
|
+
inv = out.Invoice; touched = false;
|
|
100
|
+
toast(`Invoice ${inv.DocNumber ?? ""} saved`);
|
|
101
|
+
onSaved?.();
|
|
102
|
+
if (andSend) { t.close(); sendDialog(inv, onSaved); } else t.close();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
for (const b of t.foot.querySelectorAll("button")) b.disabled = false;
|
|
105
|
+
if (error instanceof ToolError && error.is("DUPLICATE_DOC_NUMBER")) {
|
|
106
|
+
key = newKey();
|
|
107
|
+
if (await confirmDialog("Duplicate invoice number", `Invoice no. ${body.DocNumber} is already used. Use it anyway?`, "Use anyway")) save(andSend, true);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (error instanceof ToolError && error.is("STALE_OBJECT")) { formError.replaceChildren(errorBanner(error, "This invoice was changed elsewhere. Close it and open it again to see the latest version")); return; }
|
|
111
|
+
const target = error instanceof ToolError ? { CustomerRef: cust, BillEmail: email, TxnDate: txnDate, DueDate: dueDate, DocNumber: docNo }[error.element] : undefined;
|
|
112
|
+
if (target) target.setError(describe(error));
|
|
113
|
+
formError.replaceChildren(errorBanner(error, "We couldn't save this invoice"));
|
|
114
|
+
if (!(error instanceof ToolError && error.is("THROTTLE_EXCEEDED"))) key = newKey();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const more = [];
|
|
118
|
+
if (inv) {
|
|
119
|
+
more.push(el("button", { type: "button", class: "foot-link", text: "Print or download", onclick: () => pdfPreview(inv) }));
|
|
120
|
+
if (!voided) more.push(el("button", { type: "button", class: "foot-link", text: "Void", onclick: () => voidInvoice(inv, () => { t.close(); onSaved?.(); }) }));
|
|
121
|
+
more.push(el("button", { type: "button", class: "foot-link", text: "Delete", onclick: () => deleteInvoice(inv, () => { t.close(); onSaved?.(); }) }));
|
|
122
|
+
}
|
|
123
|
+
t.foot.append(el("button", { type: "button", class: "btn", text: "Cancel", onclick: t.requestClose }), ...more, el("div", { class: "grow" }),
|
|
124
|
+
el("button", { type: "button", class: "btn", text: "Save", disabled: voided, onclick: () => save(false) }),
|
|
125
|
+
el("button", { type: "button", class: "btn primary", text: "Save and send", disabled: voided, onclick: () => save(true) }));
|
|
126
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Invoice line table: Product or service / Description / Qty / Rate / Amount, with add and clear lines.
|
|
2
|
+
import { amount, el, icon } from "../ui.js";
|
|
3
|
+
|
|
4
|
+
const cents = (n) => Math.round(Number(n) * 100);
|
|
5
|
+
|
|
6
|
+
export function linesTable(items, initial, onChange) {
|
|
7
|
+
const byId = new Map(items.map((i) => [i.Id, i]));
|
|
8
|
+
const tbody = el("tbody");
|
|
9
|
+
let seq = 0;
|
|
10
|
+
const rows = [];
|
|
11
|
+
const renumber = () => rows.forEach((r, i) => { r.num.textContent = String(i + 1); });
|
|
12
|
+
function addRow(line = {}) {
|
|
13
|
+
seq += 1;
|
|
14
|
+
const d = line.SalesItemLineDetail ?? {};
|
|
15
|
+
const item = el("select", { class: "cell-input", "aria-label": `Product or service, line ${seq}` }, [el("option", { value: "", text: "" })]);
|
|
16
|
+
for (const it of items) item.append(el("option", { value: it.Id, text: it.Name }));
|
|
17
|
+
if (d.ItemRef?.value && !byId.has(d.ItemRef.value)) item.append(el("option", { value: d.ItemRef.value, text: `${d.ItemRef.name ?? d.ItemRef.value} (inactive)` }));
|
|
18
|
+
item.value = d.ItemRef?.value ?? "";
|
|
19
|
+
const desc = el("input", { class: "cell-input", type: "text", "aria-label": `Description, line ${seq}`, value: line.Description ?? "" });
|
|
20
|
+
const qty = el("input", { class: "cell-input num", type: "number", step: "any", min: "0", "aria-label": `Quantity, line ${seq}`, value: d.Qty ?? "" });
|
|
21
|
+
const rate = el("input", { class: "cell-input num", type: "number", step: "0.01", "aria-label": `Rate, line ${seq}`, value: d.UnitPrice ?? "" });
|
|
22
|
+
const amt = el("td", { class: "amt" });
|
|
23
|
+
const num = el("td", { class: "n" });
|
|
24
|
+
const r = { item, desc, qty, rate, amt, num };
|
|
25
|
+
const recalc = () => {
|
|
26
|
+
const q = Number(qty.value || 0);
|
|
27
|
+
const p = Number(rate.value || 0);
|
|
28
|
+
r.amount = qty.value === "" && rate.value === "" ? 0 : Math.round(q * p * 100) / 100;
|
|
29
|
+
amt.textContent = item.value ? amount(r.amount) : "";
|
|
30
|
+
onChange?.();
|
|
31
|
+
};
|
|
32
|
+
item.addEventListener("change", () => {
|
|
33
|
+
const it = byId.get(item.value);
|
|
34
|
+
if (it) {
|
|
35
|
+
if (!desc.value) desc.value = it.Description ?? "";
|
|
36
|
+
if (qty.value === "") qty.value = "1";
|
|
37
|
+
if (rate.value === "" && it.UnitPrice !== undefined && it.UnitPrice !== null) rate.value = String(it.UnitPrice);
|
|
38
|
+
}
|
|
39
|
+
recalc();
|
|
40
|
+
if (rows[rows.length - 1] === r && item.value) addRow();
|
|
41
|
+
});
|
|
42
|
+
for (const x of [qty, rate]) x.addEventListener("input", recalc);
|
|
43
|
+
const del = el("button", { type: "button", class: "icon-btn", "aria-label": `Delete line ${seq}`, title: "Delete line", onclick: () => {
|
|
44
|
+
if (rows.length === 1) { item.value = ""; desc.value = ""; qty.value = ""; rate.value = ""; recalc(); return; }
|
|
45
|
+
rows.splice(rows.indexOf(r), 1); tr.remove(); renumber(); onChange?.();
|
|
46
|
+
} }, icon("trash"));
|
|
47
|
+
const tr = el("tr", {}, [num, el("td", {}, item), el("td", {}, desc), el("td", { style: undefined }, qty), el("td", {}, rate), amt, el("td", { class: "del" }, del)]);
|
|
48
|
+
rows.push(r);
|
|
49
|
+
tbody.append(tr);
|
|
50
|
+
renumber();
|
|
51
|
+
recalc();
|
|
52
|
+
return r;
|
|
53
|
+
}
|
|
54
|
+
for (const line of initial) addRow(line);
|
|
55
|
+
while (rows.length < 2) addRow();
|
|
56
|
+
|
|
57
|
+
const node = el("div", {}, [
|
|
58
|
+
el("div", { class: "table-wrap" }, el("table", { class: "lines" }, [
|
|
59
|
+
el("thead", {}, el("tr", {}, [el("th", { text: "#" }), el("th", { text: "Product or service" }), el("th", { text: "Description" }), el("th", { class: "amt", text: "Qty" }), el("th", { class: "amt", text: "Rate" }), el("th", { class: "amt", text: "Amount" }), el("th", {}, el("span", { class: "visually-hidden", text: "Delete" }))])),
|
|
60
|
+
tbody,
|
|
61
|
+
])),
|
|
62
|
+
el("div", { class: "line-actions" }, [
|
|
63
|
+
el("button", { type: "button", class: "btn small", text: "Add lines", onclick: () => { addRow(); addRow(); } }),
|
|
64
|
+
el("button", { type: "button", class: "btn small", text: "Clear all lines", onclick: () => { for (const r of rows.splice(0)) r.num.parentElement.remove(); addRow(); addRow(); onChange?.(); } }),
|
|
65
|
+
]),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
node,
|
|
70
|
+
totalCents: () => rows.reduce((s, r) => s + (r.item.value ? cents(r.amount) : 0), 0),
|
|
71
|
+
/** Lines for the API body, or an error message pointing at the first bad row. */
|
|
72
|
+
collect() {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [i, r] of rows.entries()) {
|
|
75
|
+
for (const x of [r.item, r.qty, r.rate]) x.classList.remove("invalid");
|
|
76
|
+
if (!r.item.value) {
|
|
77
|
+
if (r.qty.value || r.rate.value || r.desc.value) { r.item.classList.add("invalid"); return { error: `Line ${i + 1}: select a product or service.`, focus: r.item }; }
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const qty = Number(r.qty.value || 0);
|
|
81
|
+
if (!(qty > 0)) { r.qty.classList.add("invalid"); return { error: `Line ${i + 1}: enter a quantity greater than 0.`, focus: r.qty }; }
|
|
82
|
+
const rate = Number(r.rate.value || 0);
|
|
83
|
+
const line = { DetailType: "SalesItemLineDetail", Amount: Math.round(qty * rate * 100) / 100, SalesItemLineDetail: { ItemRef: { value: r.item.value }, Qty: qty, UnitPrice: rate } };
|
|
84
|
+
if (r.desc.value.trim()) line.Description = r.desc.value.trim();
|
|
85
|
+
out.push(line);
|
|
86
|
+
}
|
|
87
|
+
if (!out.length) { rows[0].item.classList.add("invalid"); return { error: "Add at least one product or service line.", focus: rows[0].item }; }
|
|
88
|
+
return { lines: out };
|
|
89
|
+
},
|
|
90
|
+
markLine(index) { const r = rows.filter((x) => x.item.value)[index]; if (r) { r.item.classList.add("invalid"); r.item.focus(); } },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Sales & Get paid > Invoices: money bar, filters, server-side paging through query.run, row actions.
|
|
2
|
+
import { app, go, isVoided, query, queryAll, register } from "../store.js";
|
|
3
|
+
import { addDays, amount, call, describe, el, errorBanner, icon, isAccessDenied, loadingRows, mdY, money, notSimulated, quote, toast } from "../ui.js";
|
|
4
|
+
import { emptyRow, pageShell, pager, salesTabs, select, statusCell } from "./common.js";
|
|
5
|
+
import { openInvoiceEditor } from "./invoice-editor.js";
|
|
6
|
+
import { rowActions } from "./invoice-actions.js";
|
|
7
|
+
import { createSelection } from "./batch.js";
|
|
8
|
+
import { invoiceBatchActions } from "./batch-actions.js";
|
|
9
|
+
|
|
10
|
+
const SIZE = 25;
|
|
11
|
+
|
|
12
|
+
/** Money bar data: unpaid (overdue / not due) over 365 days and paid (not deposited / deposited) over 30 days. */
|
|
13
|
+
export async function moneyBarData() {
|
|
14
|
+
const today = app.today;
|
|
15
|
+
const [open, payments, undeposited] = await Promise.all([
|
|
16
|
+
queryAll(`select * from Invoice where Balance > '0' AND TxnDate >= ${quote(addDays(today, -365))}`),
|
|
17
|
+
queryAll(`select * from Payment where TxnDate >= ${quote(addDays(today, -30))}`),
|
|
18
|
+
queryAll("select * from Account where Active IN (true, false)").then((a) => new Set(a.filter((x) => x.AccountSubType === "UndepositedFunds").map((x) => x.Id))).catch(() => new Set()),
|
|
19
|
+
]);
|
|
20
|
+
const d = { overdue: 0, overdueN: 0, notDue: 0, notDueN: 0, notDeposited: 0, notDepositedN: 0, deposited: 0, depositedN: 0 };
|
|
21
|
+
for (const inv of open) {
|
|
22
|
+
if (inv.DueDate < today) { d.overdue += Number(inv.Balance); d.overdueN += 1; } else { d.notDue += Number(inv.Balance); d.notDueN += 1; }
|
|
23
|
+
}
|
|
24
|
+
for (const p of payments) {
|
|
25
|
+
if (typeof p.PrivateNote === "string" && p.PrivateNote.startsWith("Voided")) continue;
|
|
26
|
+
if (undeposited.has(p.DepositToAccountRef?.value)) { d.notDeposited += Number(p.TotalAmt); d.notDepositedN += 1; } else { d.deposited += Number(p.TotalAmt); d.depositedN += 1; }
|
|
27
|
+
}
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function moneyBar(d, selected, onPick) {
|
|
32
|
+
const seg = (key, amt, n, label, tone) => el("button", { type: "button", class: `mb-seg${selected === key ? " selected" : ""}`, "aria-pressed": selected === key ? "true" : "false", onclick: () => onPick(selected === key ? "" : key), style: undefined }, [
|
|
33
|
+
el("div", { class: "mb-amt", text: money(amt) }), el("div", { class: "mb-lbl", text: `${n} ${label}` }), el("div", { class: `mb-bar ${tone}` }),
|
|
34
|
+
]);
|
|
35
|
+
return el("div", { class: "moneybar", role: "group", "aria-label": "Invoice summary" }, [
|
|
36
|
+
el("div", {}, [el("div", { class: "mb-group-head" }, [el("span", {}, [el("strong", { text: `${money(d.overdue + d.notDue)} Unpaid` })]), el("span", { text: "Last 365 days" })]),
|
|
37
|
+
el("div", { class: "mb-segs" }, [seg("overdue", d.overdue, d.overdueN, "Overdue", "tone-orange"), seg("open", d.notDue, d.notDueN, "Not due yet", "tone-grey")])]),
|
|
38
|
+
el("div", {}, [el("div", { class: "mb-group-head" }, [el("span", {}, [el("strong", { text: `${money(d.notDeposited + d.deposited)} Paid` })]), el("span", { text: "Last 30 days" })]),
|
|
39
|
+
el("div", { class: "mb-segs" }, [seg("notdeposited", d.notDeposited, d.notDepositedN, "Not deposited", "tone-lgreen"), seg("paid", d.deposited, d.depositedN, "Deposited", "tone-green")])]),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
register("invoices", async (host, route) => {
|
|
44
|
+
const p = route.params;
|
|
45
|
+
const status = p.get("status") ?? "";
|
|
46
|
+
const customer = p.get("customer") ?? "";
|
|
47
|
+
const dates = p.get("dates") ?? "365";
|
|
48
|
+
const q = p.get("q") ?? "";
|
|
49
|
+
const start = Math.max(1, Number(p.get("start")) || 1);
|
|
50
|
+
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(`#/invoices?${n}`); };
|
|
51
|
+
const refresh = () => { if (host.isConnected) app.pages.invoices(host, route); };
|
|
52
|
+
const { root, body } = pageShell("Invoices", {
|
|
53
|
+
crumb: "Sales & Get paid",
|
|
54
|
+
actions: [
|
|
55
|
+
el("button", { type: "button", class: "btn", text: "Manage templates", onclick: () => notSimulated("Custom form styles") }),
|
|
56
|
+
el("div", { class: "split" }, [el("button", { type: "button", class: "btn primary", text: "Create invoice", onclick: () => openInvoiceEditor(null, refresh) }),
|
|
57
|
+
el("button", { type: "button", class: "btn primary", "aria-label": "More create options", onclick: () => notSimulated("Import invoices") }, icon("chevronDown"))]),
|
|
58
|
+
],
|
|
59
|
+
tabs: salesTabs("invoices"),
|
|
60
|
+
});
|
|
61
|
+
const barHost = el("div", {}, el("div", { class: "moneybar" }, [el("div", { class: "skeleton" }), el("div", { class: "skeleton" })]));
|
|
62
|
+
const search = el("input", { type: "search", placeholder: "Search by invoice no.", "aria-label": "Search by invoice number", value: q });
|
|
63
|
+
search.addEventListener("keydown", (e) => { if (e.key === "Enter") nav({ q: search.value.trim(), start: "" }); });
|
|
64
|
+
const statusSel = select([["", "All"], ["unpaid", "Unpaid"], ["overdue", "Overdue"], ["open", "Not due yet"], ["paidall", "Paid"]], ["notdeposited", "paid"].includes(status) ? "paidall" : status);
|
|
65
|
+
statusSel.setAttribute("aria-label", "Status");
|
|
66
|
+
statusSel.addEventListener("change", () => nav({ status: statusSel.value, start: "" }));
|
|
67
|
+
const dateSel = select([["365", "Last 365 days"], ["90", "Last 90 days"], ["30", "Last 30 days"], ["all", "All dates"]], dates);
|
|
68
|
+
dateSel.setAttribute("aria-label", "Date");
|
|
69
|
+
dateSel.addEventListener("change", () => nav({ dates: dateSel.value, start: "" }));
|
|
70
|
+
const custSel = select([["", "All customers"]], "");
|
|
71
|
+
custSel.setAttribute("aria-label", "Customer");
|
|
72
|
+
custSel.addEventListener("change", () => nav({ customer: custSel.value, start: "" }));
|
|
73
|
+
const sel = createSelection({ noun: "invoices", labelFor: (inv) => `invoice ${inv.DocNumber ?? inv.Id}`, actionsFor: (rows) => invoiceBatchActions(rows, refresh) });
|
|
74
|
+
const tbody = el("tbody", {}, loadingRows(7));
|
|
75
|
+
const foot = el("div");
|
|
76
|
+
const card = el("div", { class: "card" }, [
|
|
77
|
+
el("div", { class: "toolbar" }, [el("div", { class: "tsearch" }, [icon("search"), search]), el("label", { class: "filter-label" }, ["Status", statusSel]), el("label", { class: "filter-label" }, ["Date", dateSel]), el("label", { class: "filter-label" }, ["Customer", custSel]), el("div", { class: "grow" }),
|
|
78
|
+
el("button", { type: "button", class: "icon-btn", "aria-label": "Print list", title: "Print list", onclick: () => notSimulated("Print list") }, icon("print")),
|
|
79
|
+
el("button", { type: "button", class: "icon-btn", "aria-label": "Export to Excel", title: "Export to Excel", onclick: () => notSimulated("Export to Excel") }, icon("export")),
|
|
80
|
+
el("button", { type: "button", class: "icon-btn", "aria-label": "Table settings", title: "Settings", onclick: () => notSimulated("Table settings") }, icon("gear"))]),
|
|
81
|
+
sel.bar,
|
|
82
|
+
el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [el("thead", {}, el("tr", {}, [el("th", { class: "check" }, sel.head), ...["Date", "No.", "Customer", "Amount", "Status", "Action"].map((h, i) => el("th", { class: i === 3 ? "num" : i === 5 ? "act" : "", text: h }))])), tbody])),
|
|
83
|
+
foot,
|
|
84
|
+
]);
|
|
85
|
+
body.append(barHost, card);
|
|
86
|
+
host.replaceChildren(root);
|
|
87
|
+
moneyBarData().then((d) => barHost.replaceChildren(moneyBar(d, status, (key) => nav({ status: key, start: "" })))).catch((error) => (isAccessDenied(error) ? barHost.remove() : barHost.replaceChildren(errorBanner(error, "We couldn't load the invoice summary"))));
|
|
88
|
+
queryAll("select * from Customer where Active IN (true, false) orderby DisplayName").then((cs) => { for (const c of cs) custSel.append(el("option", { value: c.Id, text: c.DisplayName })); custSel.value = customer; }).catch((error) => { if (!isAccessDenied(error)) toast(`Customer filter: ${describe(error)}`, { error: true }); });
|
|
89
|
+
try {
|
|
90
|
+
const where = [];
|
|
91
|
+
if (dates !== "all") where.push(`TxnDate >= ${quote(addDays(app.today, -Number(dates)))}`);
|
|
92
|
+
if (status === "unpaid") where.push("Balance > '0'");
|
|
93
|
+
if (status === "overdue") where.push("Balance > '0'", `DueDate < ${quote(app.today)}`);
|
|
94
|
+
if (status === "open") where.push("Balance > '0'", `DueDate >= ${quote(app.today)}`);
|
|
95
|
+
if (status === "paidall" || status === "paid" || status === "notdeposited") where.push("Balance = '0'");
|
|
96
|
+
if (customer) where.push(`CustomerRef = ${quote(customer)}`);
|
|
97
|
+
if (q) where.push(`DocNumber LIKE ${quote(`%${q}%`)}`);
|
|
98
|
+
const clause = where.length ? ` where ${where.join(" AND ")}` : "";
|
|
99
|
+
const total = (await call("query.run", { query: `select count(*) from Invoice${clause}` })).QueryResponse.totalCount ?? 0;
|
|
100
|
+
const rows = await query(`select * from Invoice${clause} orderby TxnDate desc STARTPOSITION ${start} MAXRESULTS ${SIZE}`);
|
|
101
|
+
sel.reset();
|
|
102
|
+
tbody.replaceChildren(...(rows.length ? rows.map((inv) => {
|
|
103
|
+
const tr = el("tr", { class: "clickable", tabindex: "0", "aria-label": `Invoice ${inv.DocNumber ?? inv.Id}` }, [
|
|
104
|
+
sel.cell(inv),
|
|
105
|
+
el("td", { text: mdY(inv.TxnDate) }), el("td", { text: inv.DocNumber ?? "" }), el("td", { text: inv.CustomerRef?.name ?? "" }),
|
|
106
|
+
el("td", { class: "num", text: isVoided(inv) ? "$0.00" : money(inv.TotalAmt) }), el("td", {}, statusCell(inv)), el("td", { class: "act" }, rowActions(inv, refresh)),
|
|
107
|
+
]);
|
|
108
|
+
const open = () => openInvoiceEditor(inv.Id, refresh);
|
|
109
|
+
tr.addEventListener("click", (e) => { if (!e.target.closest("button, input, a")) open(); });
|
|
110
|
+
tr.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.target === tr) open(); });
|
|
111
|
+
return tr;
|
|
112
|
+
}) : [emptyRow(7, q || status || customer ? "No invoices match these filters" : "No invoices yet", q || status || customer ? "Try changing the filters." : "Create an invoice to get paid faster.")]));
|
|
113
|
+
foot.replaceChildren(pager({ start, size: SIZE, total, onChange: (s) => nav({ start: String(s) }) }));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
card.replaceWith(errorBanner(error, isAccessDenied(error) ? "You don't have access to Sales data" : "We couldn't load your invoices"));
|
|
116
|
+
}
|
|
117
|
+
void amount;
|
|
118
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Sales & Get paid > Products & services, plus the new/edit product drawer (Service and Non-inventory).
|
|
2
|
+
import { app, go, query, queryAll, register } from "../store.js";
|
|
3
|
+
import { ToolError, amount, call, describe, el, errorBanner, icon, isAccessDenied, loadingRows, newKey, notSimulated, openMenu, toast } from "../ui.js";
|
|
4
|
+
import { drawer, emptyRow, field, input, pageShell, pager, salesTabs, select } from "./common.js";
|
|
5
|
+
import { createSelection } from "./batch.js";
|
|
6
|
+
import { itemBatchActions } from "./batch-actions.js";
|
|
7
|
+
|
|
8
|
+
const SIZE = 25;
|
|
9
|
+
const typeLabel = (t) => (t === "NonInventory" ? "Non-inventory" : t);
|
|
10
|
+
|
|
11
|
+
export async function openItemDrawer(existing = null, onSaved) {
|
|
12
|
+
let accounts = [];
|
|
13
|
+
try { accounts = await queryAll("select * from Account where AccountType = 'Income'"); } catch (error) { toast(describe(error), { error: true }); return; }
|
|
14
|
+
const it = existing ?? {};
|
|
15
|
+
const type = select([["Service", "Service"], ["NonInventory", "Non-inventory"], ["Inventory", "Inventory (not simulated)", true], ["Bundle", "Bundle (not simulated)", true]], it.Type ?? "Service");
|
|
16
|
+
if (existing) type.disabled = true;
|
|
17
|
+
const f = {
|
|
18
|
+
Type: field("Item type", type),
|
|
19
|
+
Name: field("Name *", input({ value: it.Name ?? "" })),
|
|
20
|
+
Sku: field("SKU", input({ value: it.Sku ?? "" })),
|
|
21
|
+
Description: field("Description", el("textarea", { value: it.Description ?? "" }), { hint: "Description on sales forms" }),
|
|
22
|
+
UnitPrice: field("Sales price/rate", input({ type: "number", step: "0.01", min: "0", value: it.UnitPrice ?? "" })),
|
|
23
|
+
Income: field("Income account *", select([["", "Select an account"], ...accounts.map((a) => [a.Id, a.Name])], it.IncomeAccountRef?.value ?? "")),
|
|
24
|
+
};
|
|
25
|
+
const formError = el("div");
|
|
26
|
+
const key = newKey();
|
|
27
|
+
drawer(existing ? "Product/service information" : "New product/service", [formError, f.Type.wrap, f.Name.wrap, f.Sku.wrap, f.Description.wrap, el("div", { class: "row2" }, [f.UnitPrice.wrap, f.Income.wrap])], (close) => {
|
|
28
|
+
const save = el("button", { type: "button", class: "btn primary", text: "Save and close" });
|
|
29
|
+
save.addEventListener("click", async () => {
|
|
30
|
+
for (const x of Object.values(f)) x.setError("");
|
|
31
|
+
formError.replaceChildren();
|
|
32
|
+
const body = { Name: f.Name.input.value.trim(), Type: type.value };
|
|
33
|
+
if (!body.Name) return f.Name.setError("Enter a name.");
|
|
34
|
+
if (!f.Income.input.value) return f.Income.setError("Select an income account.");
|
|
35
|
+
body.IncomeAccountRef = { value: f.Income.input.value };
|
|
36
|
+
const sku = f.Sku.input.value.trim(); if (sku) body.Sku = sku;
|
|
37
|
+
const d = f.Description.input.value.trim(); if (d) body.Description = d;
|
|
38
|
+
if (f.UnitPrice.input.value !== "") body.UnitPrice = Number(f.UnitPrice.input.value);
|
|
39
|
+
if (existing) Object.assign(body, { Id: existing.Id, SyncToken: existing.SyncToken, sparse: true });
|
|
40
|
+
save.disabled = true;
|
|
41
|
+
try {
|
|
42
|
+
const out = await call("items.post", { body }, key);
|
|
43
|
+
close();
|
|
44
|
+
toast(`${out.Item.Name} saved`);
|
|
45
|
+
onSaved ? onSaved(out.Item) : go("#/items");
|
|
46
|
+
} catch (error) {
|
|
47
|
+
save.disabled = false;
|
|
48
|
+
const target = error instanceof ToolError ? { Name: "Name", Sku: "Sku", UnitPrice: "UnitPrice", IncomeAccountRef: "Income", Type: "Type", Description: "Description" }[error.element] : undefined;
|
|
49
|
+
if (target) f[target].setError(describe(error)); else formError.replaceChildren(errorBanner(error, "We couldn't save this product or service"));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return [el("button", { type: "button", class: "btn", text: "Cancel", onclick: close }), save];
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
register("items", async (host, route) => {
|
|
57
|
+
const q = route.params.get("q") ?? "";
|
|
58
|
+
const status = route.params.get("status") ?? "active";
|
|
59
|
+
const start = Math.max(1, Number(route.params.get("start")) || 1);
|
|
60
|
+
const nav = (patch) => { const p = new URLSearchParams(route.params); for (const [k, v] of Object.entries(patch)) v ? p.set(k, v) : p.delete(k); go(`#/items?${p}`); };
|
|
61
|
+
const { root, body } = pageShell("Products & services", {
|
|
62
|
+
crumb: "Sales & Get paid",
|
|
63
|
+
actions: [el("button", { type: "button", class: "btn", text: "More", onclick: () => notSimulated("Manage categories") }), el("button", { type: "button", class: "btn primary", text: "New", onclick: () => openItemDrawer(null, () => host.isConnected && app.pages.items(host, route)) })],
|
|
64
|
+
tabs: salesTabs("items"),
|
|
65
|
+
});
|
|
66
|
+
const search = el("input", { type: "search", placeholder: "Search", "aria-label": "Search products and services", value: q });
|
|
67
|
+
search.addEventListener("keydown", (e) => { if (e.key === "Enter") nav({ q: search.value.trim(), start: "" }); });
|
|
68
|
+
const stat = select([["active", "Active"], ["inactive", "Inactive"], ["all", "All"]], status);
|
|
69
|
+
stat.setAttribute("aria-label", "Status");
|
|
70
|
+
stat.addEventListener("change", () => nav({ status: stat.value, start: "" }));
|
|
71
|
+
const refresh = () => { if (host.isConnected) app.pages.items(host, route); };
|
|
72
|
+
const sel = createSelection({ noun: "products and services", labelFor: (it) => it.Name, actionsFor: (rows) => itemBatchActions(rows, refresh) });
|
|
73
|
+
const tbody = el("tbody", {}, loadingRows(8));
|
|
74
|
+
const foot = el("div");
|
|
75
|
+
const card = el("div", { class: "card" }, [
|
|
76
|
+
el("div", { class: "toolbar" }, [el("div", { class: "tsearch" }, [icon("search"), search]), el("label", { class: "filter-label" }, ["Status", stat]), el("div", { class: "grow" })]),
|
|
77
|
+
sel.bar,
|
|
78
|
+
el("div", { class: "table-wrap" }, el("table", { class: "grid" }, [el("thead", {}, el("tr", {}, [el("th", { class: "check" }, sel.head), ...["Name", "SKU", "Type", "Sales description", "Sales price", "Income account", "Action"].map((h, i) => el("th", { class: i === 4 ? "num" : i === 6 ? "act" : "", text: h }))])), tbody])),
|
|
79
|
+
foot,
|
|
80
|
+
]);
|
|
81
|
+
body.append(card);
|
|
82
|
+
host.replaceChildren(root);
|
|
83
|
+
try {
|
|
84
|
+
const where = [status === "all" ? "Active IN (true, false)" : status === "inactive" ? "Active = false" : "Active = true"];
|
|
85
|
+
if (q) where.push(`Name LIKE '%${q.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}%'`);
|
|
86
|
+
const clause = ` where ${where.join(" AND ")}`;
|
|
87
|
+
const total = (await call("query.run", { query: `select count(*) from Item${clause}` })).QueryResponse.totalCount ?? 0;
|
|
88
|
+
const rows = await query(`select * from Item${clause} orderby Name STARTPOSITION ${start} MAXRESULTS ${SIZE}`);
|
|
89
|
+
sel.reset();
|
|
90
|
+
tbody.replaceChildren(...(rows.length ? rows.map((it) => {
|
|
91
|
+
const edit = el("button", { type: "button", class: "link-btn", text: "Edit", onclick: () => openItemDrawer(it, refresh) });
|
|
92
|
+
const caret = el("button", { type: "button", class: "caret", "aria-label": `More actions for ${it.Name}`, "aria-haspopup": "menu" }, icon("chevronDown"));
|
|
93
|
+
caret.addEventListener("click", () => openMenu(caret, [
|
|
94
|
+
{ label: it.Active ? "Make inactive" : "Make active", run: async () => {
|
|
95
|
+
try { await call("items.post", { body: { Id: it.Id, SyncToken: it.SyncToken, sparse: true, Name: it.Name, Type: it.Type, Active: !it.Active } }, newKey()); toast(`${it.Name} is now ${it.Active ? "inactive" : "active"}`); refresh(); } catch (error) { toast(describe(error), { error: true }); }
|
|
96
|
+
} },
|
|
97
|
+
{ label: "Duplicate", run: () => notSimulated("Duplicate product") },
|
|
98
|
+
]));
|
|
99
|
+
return el("tr", {}, [sel.cell(it), el("td", {}, [el("span", { text: it.Name }), it.Active ? null : el("span", { class: "pill inactive", text: "Inactive" })]), el("td", { text: it.Sku ?? "" }), el("td", { text: typeLabel(it.Type) }), el("td", { class: "muted", text: it.Description ?? "" }), el("td", { class: "num", text: it.UnitPrice === undefined || it.UnitPrice === null ? "" : amount(it.UnitPrice) }), el("td", { text: it.IncomeAccountRef?.name ?? "" }), el("td", { class: "act" }, el("div", { class: "row-actions" }, [edit, caret]))]);
|
|
100
|
+
}) : [emptyRow(8, q ? "No products or services match your search" : "No products or services yet", q ? "Try a different name." : "Add the things you sell to use them on invoices.")]));
|
|
101
|
+
foot.replaceChildren(pager({ start, size: SIZE, total, onChange: (s) => nav({ start: String(s) }) }));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
card.replaceWith(errorBanner(error, isAccessDenied(error) ? "You don't have access to products and services" : "We couldn't load products and services"));
|
|
104
|
+
}
|
|
105
|
+
});
|