@firedrill-tools/netsuite 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 +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Lists → Accounting → Items: the item list with a detail panel. Items are read-only in this Tool.
|
|
2
|
+
import { go, listIds, expand, register } from "../store.js";
|
|
3
|
+
import { call, el, errorBanner, emptyState, money, qty, mdy, skeletonRows, lit } from "../ui.js";
|
|
4
|
+
import { listShell, pagerStrip, setPageTitle, toolbarButton, nsButton, fieldGroup, kv, kvColumns } from "./common.js";
|
|
5
|
+
|
|
6
|
+
const COLUMNS = [
|
|
7
|
+
{ label: "Item Name/Number" }, { label: "Display Name" }, { label: "Type" },
|
|
8
|
+
{ label: "Base Price", numeric: true }, { label: "On Hand", numeric: true }, { label: "Inactive" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
register("items", async (main, route) => {
|
|
12
|
+
setPageTitle("Items");
|
|
13
|
+
const state = {
|
|
14
|
+
term: route.params.get("q") ?? "",
|
|
15
|
+
inactive: route.params.get("inactive") === "1",
|
|
16
|
+
offset: Number(route.params.get("offset") ?? 0) || 0,
|
|
17
|
+
size: Number(route.params.get("size") ?? 25) || 25,
|
|
18
|
+
};
|
|
19
|
+
const body = el("tbody", {}, skeletonRows(COLUMNS.length));
|
|
20
|
+
const pagerHost = el("div", {});
|
|
21
|
+
const detail = el("div", {});
|
|
22
|
+
|
|
23
|
+
function apply(patch) {
|
|
24
|
+
const next = { ...state, ...patch };
|
|
25
|
+
const params = new URLSearchParams();
|
|
26
|
+
if (next.term) params.set("q", next.term);
|
|
27
|
+
if (next.inactive) params.set("inactive", "1");
|
|
28
|
+
if (next.offset) params.set("offset", String(next.offset));
|
|
29
|
+
if (next.size !== 25) params.set("size", String(next.size));
|
|
30
|
+
const query = params.toString();
|
|
31
|
+
go(`#/items${query ? `?${query}` : ""}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const input = el("input", { type: "search", id: "item-filter", value: state.term, placeholder: "Item name or display name" });
|
|
35
|
+
const inactiveBox = el("input", { type: "checkbox", id: "item-inactive", checked: state.inactive });
|
|
36
|
+
inactiveBox.addEventListener("change", () => apply({ inactive: inactiveBox.checked, offset: 0 }));
|
|
37
|
+
const filter = el("form", { class: "filterbar", onsubmit: (event) => { event.preventDefault(); apply({ term: input.value.trim(), offset: 0 }); } }, [
|
|
38
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "item-filter", text: "Quick Filter" }), input]),
|
|
39
|
+
el("div", { class: "field inline" }, [inactiveBox, el("label", { class: "fl", for: "item-inactive", text: "Show Inactives" })]),
|
|
40
|
+
toolbarButton("Search", () => apply({ term: input.value.trim(), offset: 0 })),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
async function showDetail(id) {
|
|
44
|
+
detail.replaceChildren(el("p", { class: "muted", text: "Loading item…" }));
|
|
45
|
+
try {
|
|
46
|
+
const item = await call("inventory-item.get", { recordId: id });
|
|
47
|
+
detail.replaceChildren(fieldGroup(`Item — ${item.itemId}`, kvColumns(
|
|
48
|
+
[kv("Display Name", item.displayName ?? ""), kv("Description", item.description ?? ""), kv("Base Price", money(item.basePrice))],
|
|
49
|
+
[kv("Quantity On Hand", qty(item.quantityOnHand)), kv("Income Account", item.incomeAccount?.refName ?? ""),
|
|
50
|
+
kv("Tax Schedule", item.taxSchedule?.refName ?? ""), kv("Subsidiaries", (item.subsidiary?.items ?? []).map((s) => s.refName).join(", "))],
|
|
51
|
+
)));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
detail.replaceChildren(errorBanner(error, "This item could not be opened"));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main.replaceChildren(listShell({
|
|
58
|
+
title: "Items",
|
|
59
|
+
subtitle: "Items are seeded by the world and are read-only in this Tool.",
|
|
60
|
+
actions: [nsButton("New Item"), nsButton("Customize View")],
|
|
61
|
+
filter,
|
|
62
|
+
columns: COLUMNS,
|
|
63
|
+
body,
|
|
64
|
+
pager: pagerHost,
|
|
65
|
+
}), detail);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const parts = [];
|
|
69
|
+
if (!state.inactive) parts.push("isInactive IS false");
|
|
70
|
+
if (state.term !== "") parts.push(`(itemId CONTAIN ${lit(state.term)} OR displayName CONTAIN ${lit(state.term)})`);
|
|
71
|
+
const page = await listIds("inventory-item.list", { q: parts.join(" AND "), limit: state.size, offset: state.offset });
|
|
72
|
+
const rows = await expand("inventory-item.get", page.ids);
|
|
73
|
+
if (rows.length === 0) {
|
|
74
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, emptyState("No items match this filter."))));
|
|
75
|
+
} else {
|
|
76
|
+
body.replaceChildren(...rows.map((record) => {
|
|
77
|
+
const row = el("tr", {}, [
|
|
78
|
+
el("td", {}, el("button", { type: "button", class: "btn link", text: record.itemId, onclick: () => showDetail(record.id) })),
|
|
79
|
+
el("td", { text: record.displayName ?? "" }),
|
|
80
|
+
el("td", { text: "Inventory Item" }),
|
|
81
|
+
el("td", { class: "num", text: money(record.basePrice) }),
|
|
82
|
+
el("td", { class: "num", text: qty(record.quantityOnHand) }),
|
|
83
|
+
el("td", { text: record.isInactive ? "Yes" : "No" }),
|
|
84
|
+
]);
|
|
85
|
+
return row;
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
pagerHost.replaceChildren(pagerStrip({
|
|
89
|
+
offset: page.offset, shown: rows.length, total: page.totalResults, hasMore: page.hasMore, pageSize: state.size,
|
|
90
|
+
onOffset: (offset) => apply({ offset }), onPageSize: (size) => apply({ size, offset: 0 }),
|
|
91
|
+
}));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "The item list could not be loaded"))));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Transactions → Sales → Enter Sales Orders: the transaction entry form with the customer picker,
|
|
2
|
+
// the Items line grid and live totals. Save calls sales-order.create with an idempotency key.
|
|
3
|
+
import { app, can, go, register } from "../store.js";
|
|
4
|
+
import { call, el, errorBanner, money, mdy, newKey, toast, describe, ToolError } from "../ui.js";
|
|
5
|
+
import { recordShell, setPageTitle, toolbarButton, nsButton, totalsBlock, fieldGroup } from "./common.js";
|
|
6
|
+
import { pickCustomer, pickItem } from "./pickers.js";
|
|
7
|
+
|
|
8
|
+
const TERMS = [["", "— None —"], ["1", "Net 15"], ["2", "Net 30"], ["3", "Due on receipt"]];
|
|
9
|
+
|
|
10
|
+
register("order-new", async (main, route) => {
|
|
11
|
+
setPageTitle("Sales Order");
|
|
12
|
+
if (!can("TRAN_SALESORD", "create")) {
|
|
13
|
+
main.replaceChildren(errorBanner(new Error("Your role cannot create sales orders."), "Permission Violation"));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const currency = app.session?.account?.baseCurrency ?? "";
|
|
17
|
+
const state = { customer: null, lines: [], key: newKey(), saving: false };
|
|
18
|
+
const errorHost = el("div", {});
|
|
19
|
+
const linesBody = el("tbody", {});
|
|
20
|
+
const totalsHost = el("div", {});
|
|
21
|
+
|
|
22
|
+
const customerLabel = el("span", { class: "muted", text: "No customer selected" });
|
|
23
|
+
const customerButton = toolbarButton("Select Customer…", async () => {
|
|
24
|
+
const chosen = await pickCustomer();
|
|
25
|
+
if (!chosen) return;
|
|
26
|
+
state.customer = chosen;
|
|
27
|
+
customerLabel.textContent = `${chosen.entityId} · ${chosen.subsidiary?.refName ?? ""}`;
|
|
28
|
+
customerLabel.className = "";
|
|
29
|
+
app.dirty = true;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const dateInput = el("input", { type: "date", id: "so-date", value: app.today });
|
|
33
|
+
const memoInput = el("input", { type: "text", id: "so-memo", maxlength: "999" });
|
|
34
|
+
const poInput = el("input", { type: "text", id: "so-po", maxlength: "45" });
|
|
35
|
+
const termsSelect = el("select", { id: "so-terms" }, TERMS.map(([value, label]) => el("option", { value, text: label })));
|
|
36
|
+
for (const control of [dateInput, memoInput, poInput, termsSelect]) {
|
|
37
|
+
control.addEventListener("input", () => { app.dirty = true; });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recalc() {
|
|
41
|
+
const subtotal = state.lines.reduce((sum, line) => sum + (Number(line.quantity) || 0) * (Number(line.rate) || 0), 0);
|
|
42
|
+
totalsHost.replaceChildren(totalsBlock([
|
|
43
|
+
["Subtotal", money(subtotal, currency)],
|
|
44
|
+
["Tax", "calculated on save"],
|
|
45
|
+
["Estimated Total", money(subtotal, currency), true],
|
|
46
|
+
]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function paintLines() {
|
|
50
|
+
linesBody.replaceChildren(...state.lines.map((line, index) => {
|
|
51
|
+
const quantityId = `so-line-${index}-qty`;
|
|
52
|
+
const rateId = `so-line-${index}-rate`;
|
|
53
|
+
const quantityInput = el("input", { type: "number", id: quantityId, min: "0", step: "1", value: String(line.quantity) });
|
|
54
|
+
const rateInput = el("input", { type: "number", id: rateId, min: "0", step: "0.01", value: String(line.rate) });
|
|
55
|
+
quantityInput.addEventListener("input", () => { line.quantity = Number(quantityInput.value); app.dirty = true; recalc(); });
|
|
56
|
+
rateInput.addEventListener("input", () => { line.rate = Number(rateInput.value); app.dirty = true; recalc(); });
|
|
57
|
+
return el("tr", {}, [
|
|
58
|
+
el("td", { class: "tight num", text: String(index + 1) }),
|
|
59
|
+
el("td", { text: line.label }),
|
|
60
|
+
el("td", { class: "num" }, [el("label", { class: "visually-hidden", for: quantityId, text: `Quantity for line ${index + 1}` }), quantityInput]),
|
|
61
|
+
el("td", { class: "num" }, [el("label", { class: "visually-hidden", for: rateId, text: `Rate for line ${index + 1}` }), rateInput]),
|
|
62
|
+
el("td", { class: "num", text: money((Number(line.quantity) || 0) * (Number(line.rate) || 0), currency) }),
|
|
63
|
+
el("td", {}, el("button", { type: "button", class: "btn", text: "Remove", onclick: () => { state.lines.splice(index, 1); paintLines(); recalc(); } })),
|
|
64
|
+
]);
|
|
65
|
+
}));
|
|
66
|
+
if (state.lines.length === 0) {
|
|
67
|
+
linesBody.replaceChildren(el("tr", {}, el("td", { colspan: "6", class: "muted", text: "No lines yet. Add an item to this order." })));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function addLine() {
|
|
72
|
+
const item = await pickItem();
|
|
73
|
+
if (!item) return;
|
|
74
|
+
state.lines.push({ itemId: item.id, label: `${item.itemId}${item.displayName ? ` — ${item.displayName}` : ""}`, quantity: 1, rate: Number(item.basePrice) || 0 });
|
|
75
|
+
app.dirty = true;
|
|
76
|
+
paintLines();
|
|
77
|
+
recalc();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function save() {
|
|
81
|
+
if (state.saving) return;
|
|
82
|
+
errorHost.replaceChildren();
|
|
83
|
+
if (state.customer === null) {
|
|
84
|
+
errorHost.replaceChildren(errorBanner(new Error("Please choose a customer for this sales order."), "Customer is required"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (state.lines.length === 0) {
|
|
88
|
+
errorHost.replaceChildren(errorBanner(new Error("Please enter at least one line item for this transaction."), "Items are required"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
state.saving = true;
|
|
92
|
+
try {
|
|
93
|
+
const body = {
|
|
94
|
+
entity: { id: state.customer.id },
|
|
95
|
+
tranDate: dateInput.value,
|
|
96
|
+
item: { items: state.lines.map((line) => ({ item: { id: line.itemId }, quantity: Number(line.quantity), rate: Number(line.rate) })) },
|
|
97
|
+
};
|
|
98
|
+
if (memoInput.value.trim() !== "") body.memo = memoInput.value.trim();
|
|
99
|
+
if (poInput.value.trim() !== "") body.otherRefNum = poInput.value.trim();
|
|
100
|
+
if (termsSelect.value !== "") body.terms = { id: termsSelect.value };
|
|
101
|
+
const result = await call("sales-order.create", { body }, state.key);
|
|
102
|
+
app.dirty = false;
|
|
103
|
+
toast(`Sales order created (internal id ${result.id}).`);
|
|
104
|
+
go(`#/orders/${encodeURIComponent(result.id)}`);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
state.key = newKey();
|
|
107
|
+
const path = error instanceof ToolError ? error.details?.errorPath : undefined;
|
|
108
|
+
errorHost.replaceChildren(errorBanner(error, "This sales order could not be saved"),
|
|
109
|
+
...(path ? [el("p", { class: "field-error", text: `Field: ${path}` })] : []));
|
|
110
|
+
} finally {
|
|
111
|
+
state.saving = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main.replaceChildren(recordShell({
|
|
116
|
+
title: "Sales Order",
|
|
117
|
+
subtitle: `New · ${app.session?.account?.companyName ?? ""} · ${mdy(app.today)}`,
|
|
118
|
+
actions: [
|
|
119
|
+
toolbarButton("Save", save, { kind: "primary" }),
|
|
120
|
+
toolbarButton("Cancel", () => { app.dirty = false; go("#/orders"); }),
|
|
121
|
+
nsButton("Save & New"),
|
|
122
|
+
nsButton("Reset"),
|
|
123
|
+
],
|
|
124
|
+
body: [
|
|
125
|
+
errorHost,
|
|
126
|
+
fieldGroup("Primary Information", el("div", { class: "formgrid" }, [
|
|
127
|
+
el("div", { class: "field" }, [el("span", { class: "fl" }, "Customer"), el("div", { class: "btn-row" }, [customerButton, customerLabel])]),
|
|
128
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-date", text: "Date" }), dateInput]),
|
|
129
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-po", text: "PO / Check Number" }), poInput]),
|
|
130
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-terms", text: "Terms" }), termsSelect]),
|
|
131
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-memo", text: "Memo" }), memoInput]),
|
|
132
|
+
])),
|
|
133
|
+
el("section", { class: "sublist" }, [
|
|
134
|
+
el("header", {}, [el("h3", { text: "Items" }), el("span", { class: "grow" }), toolbarButton("Add Item", addLine)]),
|
|
135
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid linegrid" }, [
|
|
136
|
+
el("thead", {}, el("tr", {}, ["Line", "Item", "Quantity", "Rate", "Amount", ""].map((label, index) =>
|
|
137
|
+
el("th", { scope: "col", class: index >= 2 && index <= 4 ? "num" : "", text: label })))),
|
|
138
|
+
linesBody,
|
|
139
|
+
])),
|
|
140
|
+
]),
|
|
141
|
+
totalsHost,
|
|
142
|
+
],
|
|
143
|
+
}));
|
|
144
|
+
paintLines();
|
|
145
|
+
recalc();
|
|
146
|
+
if (route.params.get("entity")) {
|
|
147
|
+
try {
|
|
148
|
+
const customer = await call("customer.get", { recordId: route.params.get("entity") });
|
|
149
|
+
state.customer = customer;
|
|
150
|
+
customerLabel.textContent = `${customer.entityId} · ${customer.subsidiary?.refName ?? ""}`;
|
|
151
|
+
customerLabel.className = "";
|
|
152
|
+
} catch { /* the picker stays empty */ }
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The sales order record page: header band, Items sublist with its own paging, totals block and the
|
|
2
|
+
// Bill action, which runs the !transform to an invoice (whole order or a partial quantity per line).
|
|
3
|
+
import { can, go, register, remember } from "../store.js";
|
|
4
|
+
import { call, el, errorBanner, money, qty, mdy, newKey, toast, describe } from "../ui.js";
|
|
5
|
+
import { dialog, openMenu } from "../overlay.js";
|
|
6
|
+
import {
|
|
7
|
+
recordShell, recordButtons, subtabStrip, fieldGroup, kvColumns, kv, totalsBlock,
|
|
8
|
+
setPageTitle, toolbarButton, nsButton, pill, currencyOf,
|
|
9
|
+
} from "./common.js";
|
|
10
|
+
|
|
11
|
+
const TABS = ["Items", "Shipping", "Billing", "System Information"];
|
|
12
|
+
const LINES_PER_PAGE = 10;
|
|
13
|
+
|
|
14
|
+
function lineRows(lines, currency) {
|
|
15
|
+
return lines.map((line) => el("tr", {}, [
|
|
16
|
+
el("td", { class: "tight num", text: String(line.line) }),
|
|
17
|
+
el("td", { text: line.item?.refName ?? "" }),
|
|
18
|
+
el("td", { text: line.description ?? "" }),
|
|
19
|
+
el("td", { class: "num", text: qty(line.quantity) }),
|
|
20
|
+
el("td", { class: "num", text: money(line.rate, currency) }),
|
|
21
|
+
el("td", { class: "num", text: money(line.amount, currency) }),
|
|
22
|
+
el("td", { class: "num", text: qty(line.quantityBilled ?? 0) }),
|
|
23
|
+
]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function itemsPanel(node, record, currency) {
|
|
27
|
+
let offset = 0;
|
|
28
|
+
async function paint() {
|
|
29
|
+
node.replaceChildren(el("p", { class: "muted", text: "Loading items…" }));
|
|
30
|
+
try {
|
|
31
|
+
const page = await call("sales-order.items.list", { recordId: record.id, limit: LINES_PER_PAGE, offset });
|
|
32
|
+
const lines = page.items ?? [];
|
|
33
|
+
node.replaceChildren(el("section", { class: "sublist" }, [
|
|
34
|
+
el("header", {}, [el("h3", { text: "Items" }), el("span", { class: "grow" }),
|
|
35
|
+
el("span", { class: "muted", text: `${offset + 1} to ${offset + lines.length} of ${page.totalResults}` }),
|
|
36
|
+
el("button", { type: "button", class: "btn", text: "Previous", disabled: offset === 0, onclick: () => { offset = Math.max(0, offset - LINES_PER_PAGE); paint(); } }),
|
|
37
|
+
el("button", { type: "button", class: "btn", text: "Next", disabled: page.hasMore !== true, onclick: () => { offset += LINES_PER_PAGE; paint(); } }),
|
|
38
|
+
]),
|
|
39
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
|
|
40
|
+
el("thead", {}, el("tr", {}, ["Line", "Item", "Description", "Quantity", "Rate", "Amount", "Billed"].map((label, index) =>
|
|
41
|
+
el("th", { scope: "col", class: index >= 3 ? "num" : "", text: label })))),
|
|
42
|
+
el("tbody", {}, lineRows(lines, currency)),
|
|
43
|
+
])),
|
|
44
|
+
]));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
node.replaceChildren(errorBanner(error, "The item sublist could not be loaded"));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await paint();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** NetSuite's Bill action: whole order, or a quantity per unbilled line. */
|
|
53
|
+
function openBillDialog(record, currency, refresh) {
|
|
54
|
+
const lines = (record.item?.items ?? []).filter((line) => Number(line.quantity) > Number(line.quantityBilled ?? 0));
|
|
55
|
+
if (lines.length === 0) {
|
|
56
|
+
dialog({
|
|
57
|
+
title: "Bill Sales Order",
|
|
58
|
+
body: el("p", { text: "Every line on this sales order has already been billed." }),
|
|
59
|
+
actions: [{ label: "OK", kind: "primary", run: (close) => close() }],
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const inputs = new Map();
|
|
64
|
+
const errorHost = el("div", {});
|
|
65
|
+
const table = el("table", { class: "grid linegrid" }, [
|
|
66
|
+
el("thead", {}, el("tr", {}, ["Line", "Item", "Remaining", "Quantity to Bill"].map((label, index) =>
|
|
67
|
+
el("th", { scope: "col", class: index >= 2 ? "num" : "", text: label })))),
|
|
68
|
+
el("tbody", {}, lines.map((line) => {
|
|
69
|
+
const remaining = Number(line.quantity) - Number(line.quantityBilled ?? 0);
|
|
70
|
+
const id = `bill-line-${line.line}`;
|
|
71
|
+
const input = el("input", { type: "number", id, min: "0", step: "1", max: String(remaining), value: String(remaining) });
|
|
72
|
+
inputs.set(line.line, input);
|
|
73
|
+
return el("tr", {}, [
|
|
74
|
+
el("td", { class: "tight num", text: String(line.line) }),
|
|
75
|
+
el("td", {}, el("label", { class: "fl", for: id, text: line.item?.refName ?? `Line ${line.line}` })),
|
|
76
|
+
el("td", { class: "num", text: qty(remaining) }),
|
|
77
|
+
el("td", { class: "num" }, input),
|
|
78
|
+
]);
|
|
79
|
+
})),
|
|
80
|
+
]);
|
|
81
|
+
let key = newKey();
|
|
82
|
+
let busy = false;
|
|
83
|
+
dialog({
|
|
84
|
+
title: `Bill Sales Order ${record.tranId}`,
|
|
85
|
+
wide: true,
|
|
86
|
+
body: [errorHost, el("p", { text: "Billing transforms this sales order into an invoice. Reduce a quantity to bill part of a line." }), table],
|
|
87
|
+
actions: [
|
|
88
|
+
{ label: "Cancel", run: (close) => close() },
|
|
89
|
+
{
|
|
90
|
+
label: "Bill",
|
|
91
|
+
kind: "primary",
|
|
92
|
+
run: async (close) => {
|
|
93
|
+
if (busy) return;
|
|
94
|
+
busy = true;
|
|
95
|
+
errorHost.replaceChildren();
|
|
96
|
+
const items = [];
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const value = Number(inputs.get(line.line).value);
|
|
99
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
100
|
+
errorHost.replaceChildren(el("div", { class: "banner error", role: "alert" }, el("div", { class: "banner-title", text: `Line ${line.line} needs a quantity of zero or more.` })));
|
|
101
|
+
busy = false;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (value > 0) items.push({ orderLine: line.line, quantity: value });
|
|
105
|
+
}
|
|
106
|
+
if (items.length === 0) {
|
|
107
|
+
errorHost.replaceChildren(el("div", { class: "banner error", role: "alert" }, el("div", { class: "banner-title", text: "Enter a quantity on at least one line." })));
|
|
108
|
+
busy = false;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const whole = items.length === lines.length
|
|
112
|
+
&& items.every((entry, index) => entry.quantity === Number(lines[index].quantity) - Number(lines[index].quantityBilled ?? 0));
|
|
113
|
+
try {
|
|
114
|
+
const result = await call("sales-order.transform", {
|
|
115
|
+
recordId: record.id,
|
|
116
|
+
target: "invoice",
|
|
117
|
+
transformMarker: "!transform",
|
|
118
|
+
body: whole ? {} : { item: { items } },
|
|
119
|
+
}, key);
|
|
120
|
+
toast(`Invoice created (internal id ${result.id}).`);
|
|
121
|
+
close();
|
|
122
|
+
go(`#/invoices/${encodeURIComponent(result.id)}`);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
key = newKey();
|
|
125
|
+
errorHost.replaceChildren(el("div", { class: "banner error", role: "alert" }, [
|
|
126
|
+
el("div", {}, [el("div", { class: "banner-title", text: "This order could not be billed" }), el("div", { class: "banner-detail", text: describe(error) })]),
|
|
127
|
+
]));
|
|
128
|
+
} finally {
|
|
129
|
+
busy = false;
|
|
130
|
+
if (refresh) refresh();
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
register("order", async (main, route) => {
|
|
139
|
+
let record;
|
|
140
|
+
try {
|
|
141
|
+
record = await call("sales-order.get", { recordId: route.id, expandSubResources: true });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
main.replaceChildren(errorBanner(error, "This sales order could not be opened"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
setPageTitle(`Sales Order ${record.tranId}`);
|
|
147
|
+
remember({ label: `Sales Order: ${record.tranId}`, href: `#/orders/${encodeURIComponent(record.id)}` });
|
|
148
|
+
const currency = currencyOf(record);
|
|
149
|
+
let tab = TABS[0];
|
|
150
|
+
const panel = el("div", {});
|
|
151
|
+
|
|
152
|
+
function paint() {
|
|
153
|
+
if (tab === "Items") {
|
|
154
|
+
const host = el("div", {});
|
|
155
|
+
itemsPanel(host, record, currency);
|
|
156
|
+
panel.replaceChildren(host, totalsBlock([
|
|
157
|
+
["Subtotal", money(record.subtotal, currency)],
|
|
158
|
+
["Tax Total", money(record.taxTotal, currency)],
|
|
159
|
+
["Total", money(record.total, currency), true],
|
|
160
|
+
]));
|
|
161
|
+
} else if (tab === "Shipping") {
|
|
162
|
+
panel.replaceChildren(fieldGroup("Shipping", el("p", { class: "muted", text: "Fulfilment and shipping are not simulated by this Tool; this order's lines are billed directly." })));
|
|
163
|
+
} else if (tab === "Billing") {
|
|
164
|
+
panel.replaceChildren(fieldGroup("Billing", kvColumns(
|
|
165
|
+
[kv("Customer", el("a", { href: `#/customers/${encodeURIComponent(record.entity?.id ?? "")}`, text: record.entity?.refName ?? "" })),
|
|
166
|
+
kv("Terms", record.terms?.refName ?? "— None —")],
|
|
167
|
+
[kv("Currency", currency), kv("Subsidiary", record.subsidiary?.refName ?? "")],
|
|
168
|
+
)));
|
|
169
|
+
} else {
|
|
170
|
+
panel.replaceChildren(fieldGroup("System Information", kvColumns(
|
|
171
|
+
[kv("Internal ID", record.id), kv("Created", mdy(record.createdDate))],
|
|
172
|
+
[kv("Last Modified", mdy(record.lastModifiedDate)), kv("Record Type", "salesOrder")],
|
|
173
|
+
)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const actionsButton = el("button", { type: "button", class: "btn", "aria-haspopup": "true", "aria-expanded": "false", text: "Actions ▾" });
|
|
178
|
+
actionsButton.addEventListener("click", (event) => {
|
|
179
|
+
event.stopPropagation();
|
|
180
|
+
openMenu(actionsButton, [
|
|
181
|
+
{ label: "Bill", run: () => openBillDialog(record, currency, () => go(`#/orders/${encodeURIComponent(record.id)}`)), disabled: !can("TRAN_SALESORD", "edit") },
|
|
182
|
+
{ label: "Fulfill" },
|
|
183
|
+
{ label: "Close Order" },
|
|
184
|
+
{ label: "Make Copy" },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
main.replaceChildren(recordShell({
|
|
189
|
+
title: `Sales Order #${record.tranId}`,
|
|
190
|
+
subtitle: `${record.entity?.refName ?? ""} · ${mdy(record.tranDate)} · ${record.subsidiary?.refName ?? ""}`,
|
|
191
|
+
actions: recordButtons({
|
|
192
|
+
onEdit: () => go(`#/orders/new?copy=${encodeURIComponent(record.id)}`),
|
|
193
|
+
editLabel: "Edit",
|
|
194
|
+
editDisabled: !can("TRAN_SALESORD", "edit"),
|
|
195
|
+
onBack: () => go("#/orders"),
|
|
196
|
+
actions: [
|
|
197
|
+
toolbarButton("Bill", () => openBillDialog(record, currency, () => go(`#/orders/${encodeURIComponent(record.id)}`)), {
|
|
198
|
+
disabled: !can("TRAN_SALESORD", "edit") || !can("TRAN_CUSTINVC", "create"),
|
|
199
|
+
title: "Transform this order into an invoice",
|
|
200
|
+
}),
|
|
201
|
+
actionsButton,
|
|
202
|
+
],
|
|
203
|
+
}),
|
|
204
|
+
subtabs: subtabStrip(TABS, tab, (next) => { tab = next; paint(); }),
|
|
205
|
+
body: [
|
|
206
|
+
el("div", { class: "fieldcols" }, [
|
|
207
|
+
el("dl", {}, [kv("Document Number", record.tranId), kv("Date", mdy(record.tranDate)), kv("Status", pill(record.status))]),
|
|
208
|
+
el("dl", {}, [kv("Customer", record.entity?.refName ?? ""), kv("Memo", record.memo ?? ""), kv("PO / Check Number", record.otherRefNum ?? "")]),
|
|
209
|
+
]),
|
|
210
|
+
panel,
|
|
211
|
+
],
|
|
212
|
+
}));
|
|
213
|
+
paint();
|
|
214
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Transactions → Sales → Sales Orders List: the transaction list view with a status filter and paging.
|
|
2
|
+
import { app, can, go, listIds, expand, register } from "../store.js";
|
|
3
|
+
import { el, errorBanner, emptyState, money, mdy, skeletonRows, lit } from "../ui.js";
|
|
4
|
+
import { listShell, pagerStrip, setPageTitle, toolbarButton, nsButton, pill, currencyOf } from "./common.js";
|
|
5
|
+
|
|
6
|
+
const COLUMNS = [
|
|
7
|
+
{ label: "Document Number" }, { label: "Date" }, { label: "Customer" }, { label: "Status" },
|
|
8
|
+
{ label: "Memo" }, { label: "Amount", numeric: true },
|
|
9
|
+
];
|
|
10
|
+
const STATUSES = [
|
|
11
|
+
["", "— All —"],
|
|
12
|
+
["pendingApproval", "Pending Approval"],
|
|
13
|
+
["pendingFulfillment", "Pending Fulfillment"],
|
|
14
|
+
["partiallyFulfilled", "Partially Fulfilled"],
|
|
15
|
+
["pendingBilling", "Pending Billing"],
|
|
16
|
+
["pendingBillingPartFulfilled", "Pending Billing/Partially Fulfilled"],
|
|
17
|
+
["billed", "Billed"],
|
|
18
|
+
["closed", "Closed"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
register("orders", async (main, route) => {
|
|
22
|
+
setPageTitle("Sales Orders");
|
|
23
|
+
const state = {
|
|
24
|
+
status: route.params.get("status") ?? "",
|
|
25
|
+
term: route.params.get("q") ?? "",
|
|
26
|
+
offset: Number(route.params.get("offset") ?? 0) || 0,
|
|
27
|
+
size: Number(route.params.get("size") ?? 25) || 25,
|
|
28
|
+
};
|
|
29
|
+
const body = el("tbody", {}, skeletonRows(COLUMNS.length));
|
|
30
|
+
const pagerHost = el("div", {});
|
|
31
|
+
|
|
32
|
+
function apply(patch) {
|
|
33
|
+
const next = { ...state, ...patch };
|
|
34
|
+
const params = new URLSearchParams();
|
|
35
|
+
if (next.status) params.set("status", next.status);
|
|
36
|
+
if (next.term) params.set("q", next.term);
|
|
37
|
+
if (next.offset) params.set("offset", String(next.offset));
|
|
38
|
+
if (next.size !== 25) params.set("size", String(next.size));
|
|
39
|
+
const query = params.toString();
|
|
40
|
+
go(`#/orders${query ? `?${query}` : ""}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const statusSelect = el("select", { id: "so-status", onchange: (event) => apply({ status: event.currentTarget.value, offset: 0 }) },
|
|
44
|
+
STATUSES.map(([value, label]) => el("option", { value, text: label, selected: value === state.status })));
|
|
45
|
+
const input = el("input", { type: "search", id: "so-filter", value: state.term, placeholder: "Document number or memo" });
|
|
46
|
+
const filter = el("form", { class: "filterbar", onsubmit: (event) => { event.preventDefault(); apply({ term: input.value.trim(), offset: 0 }); } }, [
|
|
47
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-status", text: "Status" }), statusSelect]),
|
|
48
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "so-filter", text: "Quick Filter" }), input]),
|
|
49
|
+
toolbarButton("Search", () => apply({ term: input.value.trim(), offset: 0 })),
|
|
50
|
+
toolbarButton("Reset", () => apply({ term: "", status: "", offset: 0 })),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
main.replaceChildren(listShell({
|
|
54
|
+
title: "Sales Orders",
|
|
55
|
+
subtitle: app.session?.subsidiaryScope?.id !== "0" ? `Restricted to ${app.session.subsidiaryScope.refName}` : undefined,
|
|
56
|
+
actions: [
|
|
57
|
+
toolbarButton("New Sales Order", () => go("#/orders/new"), { kind: "primary", disabled: !can("TRAN_SALESORD", "create"), title: can("TRAN_SALESORD", "create") ? undefined : "Your role cannot create sales orders" }),
|
|
58
|
+
nsButton("Customize View"),
|
|
59
|
+
nsButton("Export"),
|
|
60
|
+
],
|
|
61
|
+
filter,
|
|
62
|
+
columns: COLUMNS,
|
|
63
|
+
body,
|
|
64
|
+
pager: pagerHost,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const parts = [];
|
|
69
|
+
if (state.status !== "") parts.push(`status IS ${lit(state.status)}`);
|
|
70
|
+
if (state.term !== "") parts.push(`(tranId CONTAIN ${lit(state.term)} OR memo CONTAIN ${lit(state.term)})`);
|
|
71
|
+
const page = await listIds("sales-order.list", { q: parts.join(" AND "), limit: state.size, offset: state.offset });
|
|
72
|
+
const rows = await expand("sales-order.get", page.ids);
|
|
73
|
+
if (rows.length === 0) {
|
|
74
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) },
|
|
75
|
+
emptyState("No sales orders match this filter.", "Change the status filter or enter a different search."))));
|
|
76
|
+
} else {
|
|
77
|
+
body.replaceChildren(...rows.map((record) => el("tr", {}, [
|
|
78
|
+
el("td", {}, el("a", { class: "rowlink", href: `#/orders/${encodeURIComponent(record.id)}`, text: record.tranId })),
|
|
79
|
+
el("td", { class: "tight", text: mdy(record.tranDate) }),
|
|
80
|
+
el("td", {}, el("a", { href: `#/customers/${encodeURIComponent(record.entity?.id ?? "")}`, text: record.entity?.refName ?? "" })),
|
|
81
|
+
el("td", {}, pill(record.status)),
|
|
82
|
+
el("td", { text: record.memo ?? "" }),
|
|
83
|
+
el("td", { class: "num", text: money(record.total, currencyOf(record)) }),
|
|
84
|
+
])));
|
|
85
|
+
}
|
|
86
|
+
pagerHost.replaceChildren(pagerStrip({
|
|
87
|
+
offset: page.offset, shown: rows.length, total: page.totalResults, hasMore: page.hasMore, pageSize: state.size,
|
|
88
|
+
onOffset: (offset) => apply({ offset }), onPageSize: (size) => apply({ size, offset: 0 }),
|
|
89
|
+
}));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "The sales order list could not be loaded"))));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Payments → Customers → Customer Payments: the payment list, plus the open invoices a payment can be
|
|
2
|
+
// accepted against (the payment itself is recorded from the invoice, as NetSuite's transform does).
|
|
3
|
+
import { app, can, go, listIds, expand, register } from "../store.js";
|
|
4
|
+
import { el, errorBanner, emptyState, money, mdy, skeletonRows } from "../ui.js";
|
|
5
|
+
import { listShell, pagerStrip, setPageTitle, nsButton, pill, currencyOf } from "./common.js";
|
|
6
|
+
|
|
7
|
+
const COLUMNS = [
|
|
8
|
+
{ label: "Document Number" }, { label: "Date" }, { label: "Customer" }, { label: "Applied To" },
|
|
9
|
+
{ label: "Status" }, { label: "Amount", numeric: true }, { label: "Unapplied", numeric: true },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
register("payments", async (main, route) => {
|
|
13
|
+
setPageTitle("Customer Payments");
|
|
14
|
+
const size = Number(route.params.get("size") ?? 25) || 25;
|
|
15
|
+
const offset = Number(route.params.get("offset") ?? 0) || 0;
|
|
16
|
+
const body = el("tbody", {}, skeletonRows(COLUMNS.length));
|
|
17
|
+
const pagerHost = el("div", {});
|
|
18
|
+
|
|
19
|
+
const apply = (patch) => {
|
|
20
|
+
const params = new URLSearchParams();
|
|
21
|
+
const next = { size, offset, ...patch };
|
|
22
|
+
if (next.offset) params.set("offset", String(next.offset));
|
|
23
|
+
if (next.size !== 25) params.set("size", String(next.size));
|
|
24
|
+
const query = params.toString();
|
|
25
|
+
go(`#/payments${query ? `?${query}` : ""}`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
main.replaceChildren(listShell({
|
|
29
|
+
title: "Customer Payments",
|
|
30
|
+
subtitle: can("TRAN_CUSTPYMT", "create") ? "Open an invoice and choose Accept Payment to record a new payment." : undefined,
|
|
31
|
+
actions: [nsButton("Customize View"), nsButton("Export")],
|
|
32
|
+
columns: COLUMNS,
|
|
33
|
+
body,
|
|
34
|
+
pager: pagerHost,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const page = await listIds("customer-payment.list", { limit: size, offset });
|
|
39
|
+
const rows = await expand("customer-payment.get", page.ids, { expandSubResources: true });
|
|
40
|
+
if (rows.length === 0) {
|
|
41
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) },
|
|
42
|
+
emptyState("No customer payments yet.", "Accept a payment from an open invoice to create one."))));
|
|
43
|
+
} else {
|
|
44
|
+
body.replaceChildren(...rows.map((record) => {
|
|
45
|
+
const currency = currencyOf(record);
|
|
46
|
+
const applied = (record.apply?.items ?? []).map((entry) => entry.refName ?? entry.doc).join(", ");
|
|
47
|
+
return el("tr", {}, [
|
|
48
|
+
el("td", { text: record.tranId }),
|
|
49
|
+
el("td", { class: "tight", text: mdy(record.tranDate) }),
|
|
50
|
+
el("td", {}, el("a", { href: `#/customers/${encodeURIComponent(record.customer?.id ?? "")}`, text: record.customer?.refName ?? "" })),
|
|
51
|
+
el("td", { text: applied }),
|
|
52
|
+
el("td", {}, pill(record.status)),
|
|
53
|
+
el("td", { class: "num", text: money(record.payment, currency) }),
|
|
54
|
+
el("td", { class: "num", text: money(record.unapplied, currency) }),
|
|
55
|
+
]);
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
pagerHost.replaceChildren(pagerStrip({
|
|
59
|
+
offset: page.offset, shown: rows.length, total: page.totalResults, hasMore: page.hasMore, pageSize: size,
|
|
60
|
+
onOffset: (next) => apply({ offset: next }), onPageSize: (next) => apply({ size: next, offset: 0 }),
|
|
61
|
+
}));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "The payment list could not be loaded"))));
|
|
64
|
+
}
|
|
65
|
+
});
|