@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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +214 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +6905 -0
  5. package/firedrill/concurrency-limited.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/ns-analytics.drill.json +73 -0
  8. package/firedrill/ns-concurrency.drill.json +53 -0
  9. package/firedrill/ns-default-identity.drill.json +43 -0
  10. package/firedrill/ns-denied.drill.json +38 -0
  11. package/firedrill/ns-insufficient-permission.drill.json +53 -0
  12. package/firedrill/ns-invalid-login.drill.json +53 -0
  13. package/firedrill/ns-order-to-cash.drill.json +93 -0
  14. package/firedrill/ns-record-locked.drill.json +53 -0
  15. package/firedrill/ns-records.drill.json +68 -0
  16. package/firedrill/ns-role-ar-clerk.drill.json +58 -0
  17. package/firedrill/ns-role-sales-rep.drill.json +58 -0
  18. package/firedrill/ns-subsidiary-scope.drill.json +58 -0
  19. package/firedrill/ns-tight-limits.drill.json +58 -0
  20. package/firedrill/ns-transform-lost.drill.json +63 -0
  21. package/firedrill/ns-write-outage.drill.json +63 -0
  22. package/firedrill/record-locked.scenario.json +11 -0
  23. package/firedrill/tight-limits.scenario.json +17 -0
  24. package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
  25. package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
  26. package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
  27. package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
  28. package/firedrill/tools/netsuite/app/site/app.js +182 -0
  29. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
  30. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
  31. package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
  32. package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
  33. package/firedrill/tools/netsuite/app/site/icons.js +59 -0
  34. package/firedrill/tools/netsuite/app/site/index.html +68 -0
  35. package/firedrill/tools/netsuite/app/site/menus.js +111 -0
  36. package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
  37. package/firedrill/tools/netsuite/app/site/store.js +95 -0
  38. package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
  39. package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
  40. package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
  41. package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
  42. package/firedrill/tools/netsuite/app/site/ui.js +119 -0
  43. package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
  44. package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
  45. package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
  46. package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
  47. package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
  48. package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
  49. package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
  50. package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
  51. package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
  52. package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
  53. package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
  54. package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
  55. package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
  56. package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
  57. package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
  58. package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
  59. package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
  60. package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
  61. package/firedrill/tools/netsuite/behavior.mjs +13 -0
  62. package/firedrill/tools/netsuite/lib/body.mjs +185 -0
  63. package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
  64. package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
  65. package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
  66. package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
  67. package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
  68. package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
  69. package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
  70. package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
  71. package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
  72. package/firedrill/tools/netsuite/lib/project.mjs +178 -0
  73. package/firedrill/tools/netsuite/lib/q.mjs +280 -0
  74. package/firedrill/tools/netsuite/lib/session.mjs +188 -0
  75. package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
  76. package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
  77. package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
  78. package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
  79. package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
  80. package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
  81. package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
  82. package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
  83. package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
  84. package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
  85. package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
  86. package/firedrill/transform-response-lost.scenario.json +11 -0
  87. package/firedrill/world.json +7711 -0
  88. package/firedrill/write-outage.scenario.json +11 -0
  89. package/firedrill.json +5 -0
  90. package/package.json +52 -0
  91. package/starter.json +6904 -0
  92. package/test/conformance.mjs +701 -0
@@ -0,0 +1,152 @@
1
+ // The customer entry form (Lists → Relationships → Customers → New / Edit), rendered in NetSuite's
2
+ // record-dialog style. Writes go through customer.create / customer.update with an idempotency key.
3
+ import { call, el, newKey, toast, describe, ToolError } from "../ui.js";
4
+ import { dialog } from "../overlay.js";
5
+ import { app, can, go } from "../store.js";
6
+
7
+ const TERMS = [["", "— None —"], ["1", "Net 15"], ["2", "Net 30"], ["3", "Due on receipt"]];
8
+
9
+ function field(id, label, control, { required = false } = {}) {
10
+ return el("div", { class: "field" }, [
11
+ el("label", { class: "fl", for: id }, [label, required ? el("span", { class: "req", text: "*" }) : null]),
12
+ control,
13
+ ]);
14
+ }
15
+
16
+ /** record === null → New Customer; otherwise Edit Customer. onSaved receives the record id. */
17
+ export function openCustomerForm(record, { subsidiaries = [], onSaved } = {}) {
18
+ const editing = record !== null && record !== undefined;
19
+ const values = {
20
+ isPerson: editing ? record.isPerson === true : false,
21
+ companyName: editing ? record.companyName ?? "" : "",
22
+ firstName: editing ? record.firstName ?? "" : "",
23
+ lastName: editing ? record.lastName ?? "" : "",
24
+ email: editing ? record.email ?? "" : "",
25
+ phone: editing ? record.phone ?? "" : "",
26
+ subsidiary: editing ? record.subsidiary?.id ?? "" : "",
27
+ terms: editing ? record.terms?.id ?? "" : "",
28
+ creditLimit: editing ? (record.creditLimit ?? "") : "",
29
+ comments: editing ? record.comments ?? "" : "",
30
+ };
31
+ const controls = {};
32
+ const make = (name, type = "text") => {
33
+ const id = `cust-${name}`;
34
+ const node = type === "textarea"
35
+ ? el("textarea", { id, value: values[name] })
36
+ : el("input", { type, id, value: String(values[name] ?? "") });
37
+ node.addEventListener("input", () => { app.dirty = true; });
38
+ controls[name] = node;
39
+ return node;
40
+ };
41
+ const personBox = el("input", { type: "checkbox", id: "cust-isPerson", checked: values.isPerson });
42
+ const subsidiarySelect = el("select", { id: "cust-subsidiary" }, [
43
+ el("option", { value: "", text: subsidiaries.length ? "— Select —" : "Account default" }),
44
+ ...subsidiaries.map((s) => el("option", { value: s.id, text: s.name, selected: s.id === values.subsidiary })),
45
+ ]);
46
+ const termsSelect = el("select", { id: "cust-terms" },
47
+ TERMS.map(([id, label]) => el("option", { value: id, text: label, selected: id === values.terms })));
48
+ const errorHost = el("div", {});
49
+
50
+ const nameBlock = el("div", { class: "formgrid" }, [
51
+ el("div", { class: "field inline" }, [personBox, el("label", { class: "fl", for: "cust-isPerson", text: "Individual" })]),
52
+ field("cust-companyName", "Company Name", make("companyName"), { required: true }),
53
+ field("cust-firstName", "First Name", make("firstName")),
54
+ field("cust-lastName", "Last Name", make("lastName")),
55
+ ]);
56
+ const contactBlock = el("div", { class: "formgrid" }, [
57
+ field("cust-email", "Email", make("email", "email")),
58
+ field("cust-phone", "Phone", make("phone", "tel")),
59
+ field("cust-subsidiary", "Subsidiary", subsidiarySelect, { required: !editing }),
60
+ field("cust-terms", "Terms", termsSelect),
61
+ field("cust-creditLimit", "Credit Limit", make("creditLimit", "number")),
62
+ ]);
63
+ const commentBlock = field("cust-comments", "Comments", make("comments", "textarea"));
64
+
65
+ let saving = false;
66
+ let key = newKey();
67
+
68
+ function body() {
69
+ const person = personBox.checked;
70
+ controls.companyName.closest(".field").hidden = false;
71
+ controls.firstName.closest(".field").hidden = !person;
72
+ controls.lastName.closest(".field").hidden = !person;
73
+ const payload = {};
74
+ const put = (name, value) => { if (value !== "" && value !== undefined) payload[name] = value; };
75
+ payload.isPerson = person;
76
+ put("companyName", controls.companyName.value.trim());
77
+ if (person) {
78
+ put("firstName", controls.firstName.value.trim());
79
+ put("lastName", controls.lastName.value.trim());
80
+ }
81
+ put("email", controls.email.value.trim());
82
+ put("phone", controls.phone.value.trim());
83
+ put("comments", controls.comments.value.trim());
84
+ const limit = controls.creditLimit.value.trim();
85
+ if (limit !== "") payload.creditLimit = Number(limit);
86
+ if (subsidiarySelect.value !== "") payload.subsidiary = { id: subsidiarySelect.value };
87
+ if (termsSelect.value !== "") payload.terms = { id: termsSelect.value };
88
+ return payload;
89
+ }
90
+
91
+ personBox.addEventListener("change", () => {
92
+ controls.firstName.closest(".field").hidden = !personBox.checked;
93
+ controls.lastName.closest(".field").hidden = !personBox.checked;
94
+ });
95
+ controls.firstName.closest(".field").hidden = !values.isPerson;
96
+ controls.lastName.closest(".field").hidden = !values.isPerson;
97
+
98
+ if (subsidiaries.length === 0 && can("LIST_SUBSIDIARY")) {
99
+ call("subsidiary.list", { limit: 50, offset: 0 })
100
+ .then((page) => {
101
+ for (const item of page.items ?? []) {
102
+ subsidiarySelect.append(el("option", { value: item.id, text: item.name, selected: item.id === values.subsidiary }));
103
+ }
104
+ subsidiarySelect.querySelector("option").textContent = "— Select —";
105
+ })
106
+ .catch(() => { /* the select keeps the account default */ });
107
+ }
108
+
109
+ const box = dialog({
110
+ title: editing ? `Edit Customer — ${record.entityId ?? record.id}` : "New Customer",
111
+ wide: true,
112
+ body: [errorHost, nameBlock, contactBlock, commentBlock,
113
+ el("p", { class: "muted", text: "Address book entries are seeded by the world and are not editable in this Tool." })],
114
+ actions: [
115
+ { label: "Cancel", run: (close) => { app.dirty = false; close(); } },
116
+ {
117
+ label: "Save",
118
+ kind: "primary",
119
+ run: async (close) => {
120
+ if (saving) return;
121
+ saving = true;
122
+ errorHost.replaceChildren();
123
+ try {
124
+ const payload = body();
125
+ const result = editing
126
+ ? await call("customer.update", { recordId: record.id, body: payload }, key)
127
+ : await call("customer.create", { body: payload }, key);
128
+ app.dirty = false;
129
+ toast(editing ? "Customer saved." : `Customer created (internal id ${result.id}).`);
130
+ close();
131
+ if (onSaved) await onSaved(result.id);
132
+ else go(`#/customers/${encodeURIComponent(result.id)}`);
133
+ } catch (error) {
134
+ key = newKey();
135
+ const path = error instanceof ToolError ? error.details?.errorPath : undefined;
136
+ errorHost.replaceChildren(el("div", { class: "banner error", role: "alert" }, [
137
+ el("div", {}, [
138
+ el("div", { class: "banner-title", text: editing ? "The customer could not be saved" : "The customer could not be created" }),
139
+ el("div", { class: "banner-detail", text: describe(error) }),
140
+ ]),
141
+ ]));
142
+ if (path && controls[path]) controls[path].focus();
143
+ } finally {
144
+ saving = false;
145
+ }
146
+ },
147
+ },
148
+ ],
149
+ onClose: () => { app.dirty = false; },
150
+ });
151
+ return box;
152
+ }
@@ -0,0 +1,147 @@
1
+ // The customer record page: NetSuite's subtab layout (Primary Information, Address, Financial,
2
+ // Transactions, System Information) with Edit, Delete and the related-transaction sublists.
3
+ import { app, can, go, listIds, expand, register, remember } from "../store.js";
4
+ import { call, el, errorBanner, money, mdy, describe, newKey, toast, lit } from "../ui.js";
5
+ import { confirmDialog, openMenu } from "../overlay.js";
6
+ import {
7
+ recordShell, recordButtons, subtabStrip, fieldGroup, kvColumns, kv, setPageTitle,
8
+ toolbarButton, nsButton, pill, currencyOf,
9
+ } from "./common.js";
10
+ import { openCustomerForm } from "./customer-form.js";
11
+
12
+ const TABS = ["Primary Information", "Address", "Financial", "Transactions", "System Information"];
13
+
14
+ function addressCard(entry) {
15
+ const address = entry.addressBookAddress ?? {};
16
+ const lines = [address.addressee, address.addr1, address.addr2,
17
+ [address.city, address.state, address.zip].filter(Boolean).join(" "), address.country?.refName]
18
+ .filter((line) => line !== undefined && line !== null && line !== "");
19
+ return el("div", { class: "fieldgroup" }, [
20
+ el("h3", { text: `${entry.label ?? "Address"}${entry.defaultBilling ? " · Default Billing" : ""}${entry.defaultShipping ? " · Default Shipping" : ""}` }),
21
+ el("div", { class: "fg-body" }, lines.map((line) => el("div", { text: line }))),
22
+ ]);
23
+ }
24
+
25
+ async function transactionsTab(node, customer) {
26
+ const blocks = [];
27
+ if (can("TRAN_SALESORD")) blocks.push(["Sales Orders", "sales-order.list", "sales-order.get", "#/orders/"]);
28
+ if (can("TRAN_CUSTINVC")) blocks.push(["Invoices", "invoice.list", "invoice.get", "#/invoices/"]);
29
+ if (blocks.length === 0) {
30
+ node.replaceChildren(el("p", { class: "muted", text: "This role cannot view transactions." }));
31
+ return;
32
+ }
33
+ node.replaceChildren(el("p", { class: "muted", text: "Loading transactions…" }));
34
+ const sections = [];
35
+ for (const [title, listOperation, getOperation, base] of blocks) {
36
+ try {
37
+ const page = await listIds(listOperation, { q: `entity ANY_OF [${customer.id}]`, limit: 10, offset: 0 });
38
+ const rows = await expand(getOperation, page.ids);
39
+ const currency = currencyOf(customer);
40
+ sections.push(el("section", { class: "sublist" }, [
41
+ el("header", {}, [el("h3", { text: title }), el("span", { class: "grow" }),
42
+ el("span", { class: "muted", text: `${rows.length} of ${page.totalResults}` })]),
43
+ el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
44
+ el("thead", {}, el("tr", {}, ["Document Number", "Date", "Status", "Amount"].map((label, index) =>
45
+ el("th", { scope: "col", class: index === 3 ? "num" : "", text: label })))),
46
+ el("tbody", {}, rows.length === 0
47
+ ? [el("tr", {}, el("td", { colspan: "4", class: "muted", text: `No ${title.toLowerCase()} for this customer.` }))]
48
+ : rows.map((row) => el("tr", {}, [
49
+ el("td", {}, el("a", { href: `${base}${encodeURIComponent(row.id)}`, text: row.tranId })),
50
+ el("td", { class: "tight", text: mdy(row.tranDate) }),
51
+ el("td", {}, pill(row.status)),
52
+ el("td", { class: "num", text: money(row.total, currency) }),
53
+ ]))),
54
+ ])),
55
+ ]));
56
+ } catch (error) {
57
+ sections.push(errorBanner(error, `${title} could not be loaded`));
58
+ }
59
+ }
60
+ node.replaceChildren(...sections);
61
+ }
62
+
63
+ register("customer", async (main, route) => {
64
+ const id = route.id;
65
+ let record;
66
+ try {
67
+ record = await call("customer.get", { recordId: id, expandSubResources: true });
68
+ } catch (error) {
69
+ main.replaceChildren(errorBanner(error, "This customer could not be opened"));
70
+ return;
71
+ }
72
+ const name = record.entityId ?? record.companyName ?? record.id;
73
+ setPageTitle(name);
74
+ remember({ label: `Customer: ${name}`, href: `#/customers/${encodeURIComponent(record.id)}` });
75
+ const currency = currencyOf(record);
76
+ let tab = TABS.includes(route.params.get("tab")) ? route.params.get("tab") : TABS[0];
77
+ const panel = el("div", {});
78
+
79
+ function paint() {
80
+ if (tab === "Primary Information") {
81
+ panel.replaceChildren(fieldGroup("Primary Information", kvColumns(
82
+ [kv("Customer ID", record.entityId), kv("Company Name", record.companyName ?? ""), kv("Individual", record.isPerson ? "Yes" : "No"),
83
+ kv("Email", record.email ? el("a", { href: `mailto:${record.email}`, text: record.email }) : "")],
84
+ [kv("Phone", record.phone ?? ""), kv("Subsidiary", record.subsidiary?.refName ?? ""), kv("Primary Currency", currency),
85
+ kv("Status", record.isInactive ? "Inactive" : "Active")],
86
+ )), ...(record.comments ? [fieldGroup("Comments", el("div", { text: record.comments }))] : []));
87
+ } else if (tab === "Address") {
88
+ const entries = record.addressBook?.items ?? [];
89
+ panel.replaceChildren(...(entries.length ? entries.map(addressCard) : [el("p", { class: "muted", text: "No addresses on this customer." })]));
90
+ } else if (tab === "Financial") {
91
+ panel.replaceChildren(fieldGroup("Financial", kvColumns(
92
+ [kv("Terms", record.terms?.refName ?? "— None —"), kv("Credit Limit", record.creditLimit === undefined ? "" : money(record.creditLimit, currency))],
93
+ [kv("Balance", money(record.balance, currency)), kv("Unbilled Orders", money(record.unbilledOrders, currency)),
94
+ kv("Overdue Balance", el("span", { class: Number(record.overdueBalance) > 0 ? "overdue" : "", text: money(record.overdueBalance, currency) }))],
95
+ )));
96
+ } else if (tab === "Transactions") {
97
+ transactionsTab(panel, record);
98
+ } else {
99
+ panel.replaceChildren(fieldGroup("System Information", kvColumns(
100
+ [kv("Internal ID", record.id), kv("Date Created", mdy(record.dateCreated))],
101
+ [kv("Last Modified", mdy(record.lastModifiedDate)), kv("Record Type", "customer")],
102
+ )));
103
+ }
104
+ }
105
+
106
+ async function remove() {
107
+ const confirmed = await confirmDialog(
108
+ "Delete Customer",
109
+ `Delete ${name}? NetSuite refuses to delete a customer that has transactions.`,
110
+ "Delete",
111
+ "danger",
112
+ );
113
+ if (!confirmed) return;
114
+ try {
115
+ await call("customer.delete", { recordId: record.id }, newKey());
116
+ toast(`${name} was deleted.`);
117
+ go("#/customers");
118
+ } catch (error) {
119
+ main.prepend(errorBanner(error, "This customer could not be deleted"));
120
+ }
121
+ }
122
+
123
+ const actionsButton = el("button", { type: "button", class: "btn", "aria-haspopup": "true", "aria-expanded": "false", text: "Actions ▾" });
124
+ actionsButton.addEventListener("click", (event) => {
125
+ event.stopPropagation();
126
+ openMenu(actionsButton, [
127
+ { label: "Delete", run: remove, disabled: !can("LIST_CUSTJOB", "full") },
128
+ { label: "Make Inactive" },
129
+ { label: "Merge" },
130
+ { label: "New Sales Order", run: () => go(`#/orders/new?entity=${encodeURIComponent(record.id)}`) },
131
+ ]);
132
+ });
133
+
134
+ main.replaceChildren(recordShell({
135
+ title: name,
136
+ subtitle: `Customer · internal id ${record.id} · ${record.subsidiary?.refName ?? ""}`,
137
+ actions: recordButtons({
138
+ onEdit: () => openCustomerForm(record, { onSaved: () => go(`#/customers/${encodeURIComponent(record.id)}`) }),
139
+ editDisabled: !can("LIST_CUSTJOB", "edit"),
140
+ onBack: () => go("#/customers"),
141
+ actions: [actionsButton],
142
+ }),
143
+ subtabs: subtabStrip(TABS, tab, (next) => { tab = next; paint(); }),
144
+ body: panel,
145
+ }));
146
+ paint();
147
+ });
@@ -0,0 +1,111 @@
1
+ // Lists → Relationships → Customers: the customer list view with the NetSuite quick filter, paging and New.
2
+ import { app, can, go, listIds, expand, register } from "../store.js";
3
+ import { el, errorBanner, emptyState, lit, money, skeletonRows } from "../ui.js";
4
+ import { listShell, pagerStrip, setPageTitle, toolbarButton, nsButton, currencyOf } from "./common.js";
5
+ import { openCustomerForm } from "./customer-form.js";
6
+
7
+ const COLUMNS = [
8
+ { label: "Name" }, { label: "Internal ID" }, { label: "Email" }, { label: "Phone" },
9
+ { label: "Subsidiary" }, { label: "Balance", numeric: true }, { label: "Overdue", numeric: true },
10
+ ];
11
+
12
+ function filterExpression(term, showInactive) {
13
+ const parts = [];
14
+ if (term !== "") {
15
+ const value = lit(term);
16
+ parts.push(`(entityId CONTAIN ${value} OR companyName CONTAIN ${value} OR email CONTAIN ${value} OR lastName CONTAIN ${value})`);
17
+ }
18
+ if (!showInactive) parts.push("isInactive IS false");
19
+ return parts.join(" AND ");
20
+ }
21
+
22
+ function row(record) {
23
+ const currency = currencyOf(record);
24
+ return el("tr", {}, [
25
+ el("td", {}, el("a", { class: "rowlink", href: `#/customers/${encodeURIComponent(record.id)}`, text: record.entityId ?? record.id })),
26
+ el("td", { class: "tight mono", text: record.id }),
27
+ el("td", { text: record.email ?? "" }),
28
+ el("td", { class: "tight", text: record.phone ?? "" }),
29
+ el("td", { text: record.subsidiary?.refName ?? "" }),
30
+ el("td", { class: "num", text: money(record.balance, currency) }),
31
+ el("td", { class: `num${Number(record.overdueBalance) > 0 ? " overdue" : ""}`, text: money(record.overdueBalance, currency) }),
32
+ ]);
33
+ }
34
+
35
+ register("customers", async (main, route) => {
36
+ setPageTitle("Customers");
37
+ const state = {
38
+ term: route.params.get("q") ?? "",
39
+ inactive: route.params.get("inactive") === "1",
40
+ offset: Number(route.params.get("offset") ?? 0) || 0,
41
+ size: Number(route.params.get("size") ?? 25) || 25,
42
+ };
43
+ const body = el("tbody", {}, skeletonRows(COLUMNS.length));
44
+ const pagerHost = el("div", {});
45
+
46
+ const searchId = "cust-quick-filter";
47
+ const inactiveId = "cust-show-inactive";
48
+ const input = el("input", { type: "search", id: searchId, value: state.term, placeholder: "Name, e-mail or company" });
49
+ const inactiveBox = el("input", { type: "checkbox", id: inactiveId, checked: state.inactive });
50
+ const form = el("form", { class: "filterbar", onsubmit: (event) => { event.preventDefault(); apply({ term: input.value.trim(), offset: 0 }); } }, [
51
+ el("div", { class: "field" }, [el("label", { class: "fl", for: searchId, text: "Quick Filter" }), input]),
52
+ el("div", { class: "field inline" }, [inactiveBox, el("label", { class: "fl", for: inactiveId, text: "Show Inactives" })]),
53
+ toolbarButton("Search", () => apply({ term: input.value.trim(), offset: 0 })),
54
+ toolbarButton("Reset", () => apply({ term: "", inactive: false, offset: 0 })),
55
+ ]);
56
+ inactiveBox.addEventListener("change", () => apply({ inactive: inactiveBox.checked, offset: 0 }));
57
+
58
+ function apply(patch) {
59
+ const next = { ...state, ...patch };
60
+ const params = new URLSearchParams();
61
+ if (next.term) params.set("q", next.term);
62
+ if (next.inactive) params.set("inactive", "1");
63
+ if (next.offset) params.set("offset", String(next.offset));
64
+ if (next.size !== 25) params.set("size", String(next.size));
65
+ const query = params.toString();
66
+ go(`#/customers${query ? `?${query}` : ""}`);
67
+ }
68
+
69
+ main.replaceChildren(listShell({
70
+ title: "Customers",
71
+ subtitle: app.session?.subsidiaryScope?.id !== "0" ? `Restricted to ${app.session.subsidiaryScope.refName}` : undefined,
72
+ actions: [
73
+ can("LIST_CUSTJOB", "create")
74
+ ? toolbarButton("New Customer", () => openCustomerForm(null), { kind: "primary" })
75
+ : toolbarButton("New Customer", () => {}, { disabled: true, title: "Your role cannot create customers" }),
76
+ nsButton("Customize View"),
77
+ nsButton("Export"),
78
+ ],
79
+ filter: form,
80
+ columns: COLUMNS,
81
+ body,
82
+ pager: pagerHost,
83
+ }));
84
+
85
+ if (route.params.get("new") === "1" && can("LIST_CUSTJOB", "create")) openCustomerForm(null);
86
+
87
+ try {
88
+ const q = filterExpression(state.term, state.inactive);
89
+ const page = await listIds("customer.list", { q, limit: state.size, offset: state.offset });
90
+ const rows = await expand("customer.get", page.ids);
91
+ if (rows.length === 0) {
92
+ body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, emptyState(
93
+ "No customers match this filter.",
94
+ state.term ? `Nothing found for "${state.term}".` : "Create a customer to get started.",
95
+ ))));
96
+ } else {
97
+ body.replaceChildren(...rows.map(row));
98
+ }
99
+ pagerHost.replaceChildren(pagerStrip({
100
+ offset: page.offset,
101
+ shown: rows.length,
102
+ total: page.totalResults,
103
+ hasMore: page.hasMore,
104
+ pageSize: state.size,
105
+ onOffset: (offset) => apply({ offset }),
106
+ onPageSize: (size) => apply({ size, offset: 0 }),
107
+ }));
108
+ } catch (error) {
109
+ body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "The customer list could not be loaded"))));
110
+ }
111
+ });
@@ -0,0 +1,75 @@
1
+ // Home portlets that are part of the real NetSuite frame but carry no simulated records: the Navigation portlet
2
+ // (the menu tree, live for the pages this Tool serves), the Calendar (a month grid on the account date, with no
3
+ // events because activities are not simulated) and the Tasks / Phone Calls activity lists. Nothing here invents
4
+ // data: every list says plainly that the feature is outside this Tool's scope.
5
+ import { app } from "../store.js";
6
+ import { el, icon, dayNum } from "../ui.js";
7
+ import { notSimulated } from "../overlay.js";
8
+ import { MENUS } from "../menus.js";
9
+ import { nsButton } from "./common.js";
10
+
11
+ const NAV_MENUS = ["Transactions", "Lists", "Reports", "Analytics", "Setup"];
12
+
13
+ function navEntry(menu, entry) {
14
+ if (entry.run) return el("li", {}, el("button", { type: "button", class: "nav-link", text: entry.label, onclick: entry.run }));
15
+ return el("li", {}, el("button", { type: "button", class: "nav-link off", text: entry.label, onclick: () => notSimulated(`${menu} › ${entry.label}`) }));
16
+ }
17
+
18
+ /** Navigation portlet: one collapsible branch per menu, the first branch open, each leaf routed or "not simulated". */
19
+ export function navigationBody() {
20
+ const branches = NAV_MENUS.filter((name) => MENUS[name]).map((name, index) => {
21
+ const leaves = MENUS[name].filter((entry) => entry.label);
22
+ const list = el("ul", { class: "nav-tree", hidden: index !== 0 }, leaves.map((entry) => navEntry(name, entry)));
23
+ const toggle = el("button", {
24
+ type: "button", class: "nav-branch", "aria-expanded": index === 0 ? "true" : "false",
25
+ onclick: () => { const open = list.hidden; list.hidden = !open; toggle.setAttribute("aria-expanded", String(open)); },
26
+ }, [icon("chevron", 12), el("span", { text: name }), el("span", { class: "muted", text: `(${leaves.length})` })]);
27
+ return el("li", {}, [toggle, list]);
28
+ });
29
+ return el("ul", { class: "nav-root" }, branches);
30
+ }
31
+
32
+ const DOW = ["S", "M", "T", "W", "T", "F", "S"];
33
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
34
+
35
+ /** Calendar portlet: the month that contains the account date, today highlighted; events are not simulated. */
36
+ export function calendarBody() {
37
+ const today = dayNum(app.today);
38
+ if (Number.isNaN(today)) return el("p", { class: "muted", text: "The account date is unavailable, so the calendar cannot be drawn." });
39
+ const [y, m] = app.today.split("-").map(Number);
40
+ const first = new Date(Date.UTC(y, m - 1, 1));
41
+ const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
42
+ const cells = [];
43
+ for (let i = 0; i < first.getUTCDay(); i += 1) cells.push(el("td", { class: "pad" }));
44
+ for (let day = 1; day <= daysInMonth; day += 1) {
45
+ const isToday = dayNum(`${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`) === today;
46
+ cells.push(el("td", { class: isToday ? "today" : "", text: String(day), "aria-current": isToday ? "date" : undefined }));
47
+ }
48
+ while (cells.length % 7 !== 0) cells.push(el("td", { class: "pad" }));
49
+ const rows = [];
50
+ for (let i = 0; i < cells.length; i += 7) rows.push(el("tr", {}, cells.slice(i, i + 7)));
51
+ return [
52
+ el("div", { class: "cal-head" }, [
53
+ el("strong", { text: `${MONTHS[m - 1]} ${y}` }),
54
+ el("span", { class: "muted", text: "Account date" }),
55
+ ]),
56
+ el("table", { class: "cal" }, [
57
+ el("thead", {}, el("tr", {}, DOW.map((d, i) => el("th", { text: d, "aria-label": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][i] })))),
58
+ el("tbody", {}, rows),
59
+ ]),
60
+ el("p", { class: "muted", text: "Events, tasks and phone calls are not simulated by this Tool, so the calendar shows no entries." }),
61
+ el("div", { class: "btn-row" }, [nsButton("New Event"), nsButton("View Calendar")]),
62
+ ];
63
+ }
64
+
65
+ /** Tasks and Phone Calls: the activity portlets a daily user sees, rendered as documented not-simulated lists. */
66
+ export function activityBody(kind) {
67
+ const singular = kind === "Tasks" ? "Task" : "Phone Call";
68
+ return [
69
+ el("table", { class: "search-results" }, [
70
+ el("thead", {}, el("tr", {}, ["Date", "Title", "Status", "Priority"].map((h) => el("th", { text: h })))),
71
+ el("tbody", {}, el("tr", {}, el("td", { colspan: "4", class: "muted", text: `${kind} are not simulated by this Tool; this list is intentionally empty.` }))),
72
+ ]),
73
+ el("div", { class: "btn-row" }, [nsButton(`New ${singular}`), nsButton(`View All ${kind}`)]),
74
+ ];
75
+ }
@@ -0,0 +1,153 @@
1
+ // Data-driven home portlets: Trend Graphs (invoiced sales by month), Report Snapshots (sales by customer) and
2
+ // Custom Search (open invoices). Every bar and row is computed from this Tool's own invoice collection; the
3
+ // scan is paged (never truncated silently) and says so when it stops at the KPI row cap.
4
+ import { app, can, listIds, expand } from "../store.js";
5
+ import { el, money, mdy, errorBanner, emptyState, isDenied } from "../ui.js";
6
+
7
+ export const MAX_KPI_ROWS = 500;
8
+ const SVG = "http://www.w3.org/2000/svg";
9
+
10
+ function svg(tag, attrs = {}, children = []) {
11
+ const node = document.createElementNS(SVG, tag);
12
+ for (const [k, v] of Object.entries(attrs)) {
13
+ if (v === undefined || v === null || v === false) continue;
14
+ if (k === "text") node.textContent = String(v);
15
+ else node.setAttribute(k, String(v));
16
+ }
17
+ for (const child of [].concat(children)) if (child) node.append(child);
18
+ return node;
19
+ }
20
+
21
+ /** Every invoice the role can read, paged; `capped` when the KPI row cap stopped the scan. */
22
+ export async function loadInvoices(q) {
23
+ const rows = [];
24
+ let capped = false;
25
+ for (let offset = 0; ; offset += 50) {
26
+ const page = await listIds("invoice.list", { q, limit: 50, offset });
27
+ rows.push(...await expand("invoice.get", page.ids));
28
+ if (!page.hasMore) break;
29
+ if (rows.length >= MAX_KPI_ROWS) { capped = true; break; }
30
+ }
31
+ return { rows, capped };
32
+ }
33
+
34
+ const capNote = (capped) => capped ? el("p", { class: "muted", text: `Computed over the first ${MAX_KPI_ROWS} matching invoices.` }) : document.createDocumentFragment();
35
+ const COUNTED = new Set(["Open", "Paid In Full"]);
36
+ const monthKey = (date) => String(date ?? "").slice(0, 7);
37
+ function lastMonths(today, count) {
38
+ const [y, m] = today.split("-").map(Number);
39
+ const keys = [];
40
+ for (let i = count - 1; i >= 0; i -= 1) {
41
+ const d = new Date(Date.UTC(y, m - 1 - i, 1));
42
+ keys.push(d.toISOString().slice(0, 7));
43
+ }
44
+ return keys;
45
+ }
46
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
47
+ const monthLabel = (key) => MONTHS[Number(key.slice(5, 7)) - 1] ?? key;
48
+
49
+ /** Trend Graphs: invoiced sales (open + paid invoices) per month for the last six account months, as an SVG bar chart. */
50
+ export async function trendBody(node) {
51
+ if (!can("TRAN_CUSTINVC")) {
52
+ node.replaceChildren(el("p", { class: "muted", text: "The Invoice permission is required for this graph." }));
53
+ return;
54
+ }
55
+ try {
56
+ const currency = app.session?.account?.baseCurrency ?? "";
57
+ const { rows, capped } = await loadInvoices('(status IS "open" OR status IS "paidInFull")');
58
+ const keys = lastMonths(app.today, 6);
59
+ const totals = new Map(keys.map((k) => [k, 0]));
60
+ for (const row of rows) {
61
+ const key = monthKey(row.tranDate);
62
+ if (totals.has(key) && COUNTED.has(String(row.status?.refName))) totals.set(key, totals.get(key) + (Number(row.total) || 0));
63
+ }
64
+ const max = Math.max(1, ...totals.values());
65
+ const W = 300; const H = 120; const pad = 14; const bw = (W - pad * 2) / keys.length;
66
+ const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, class: "trend", role: "img", "aria-label": "Sales by month, last six months" });
67
+ chart.append(svg("line", { x1: pad, y1: H - 18, x2: W - pad, y2: H - 18, class: "axis" }));
68
+ keys.forEach((key, i) => {
69
+ const value = totals.get(key);
70
+ const h = Math.round((value / max) * (H - 40));
71
+ const x = pad + i * bw + bw * 0.2;
72
+ chart.append(svg("title", { text: `${monthLabel(key)}: ${money(value, currency)}` }));
73
+ chart.append(svg("rect", { x, y: H - 18 - h, width: bw * 0.6, height: h, class: key === monthKey(app.today) ? "bar current" : "bar" }));
74
+ chart.append(svg("text", { x: x + bw * 0.3, y: H - 5, class: "tick", "text-anchor": "middle", text: monthLabel(key) }));
75
+ });
76
+ const sum = [...totals.values()].reduce((a, b) => a + b, 0);
77
+ node.replaceChildren(
78
+ el("div", { class: "trend-head" }, [el("strong", { text: "Sales" }), el("span", { class: "muted", text: `Last 6 months · ${money(sum, currency)}` })]),
79
+ chart,
80
+ el("p", { class: "muted", text: "Invoiced totals (open and paid invoices) by transaction month." }),
81
+ capNote(capped),
82
+ );
83
+ } catch (error) {
84
+ node.replaceChildren(isDenied(error) ? errorBanner(error) : errorBanner(error, "Trend graph could not be computed"));
85
+ }
86
+ }
87
+
88
+ /** Report Snapshots: Sales by Customer, the five customers with the largest invoiced totals. */
89
+ export async function snapshotBody(node) {
90
+ if (!can("TRAN_CUSTINVC")) {
91
+ node.replaceChildren(el("p", { class: "muted", text: "The Invoice permission is required for this snapshot." }));
92
+ return;
93
+ }
94
+ try {
95
+ const currency = app.session?.account?.baseCurrency ?? "";
96
+ const { rows, capped } = await loadInvoices('(status IS "open" OR status IS "paidInFull")');
97
+ const byCustomer = new Map();
98
+ for (const row of rows) {
99
+ const id = String(row.entity?.id ?? "");
100
+ const entry = byCustomer.get(id) ?? { id, name: row.entity?.refName ?? id, total: 0 };
101
+ entry.total += Number(row.total) || 0;
102
+ byCustomer.set(id, entry);
103
+ }
104
+ const top = [...byCustomer.values()].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)).slice(0, 5);
105
+ const max = Math.max(1, ...top.map((entry) => entry.total));
106
+ if (top.length === 0) { node.replaceChildren(emptyState("No results", "No open or paid invoices are visible to this role.")); return; }
107
+ node.replaceChildren(
108
+ el("div", { class: "snap-title", text: "Sales by Customer" }),
109
+ el("ul", { class: "snap-bars" }, top.map((entry) => el("li", {}, [
110
+ el("a", { href: `#/customers/${encodeURIComponent(entry.id)}`, text: entry.name }),
111
+ svg("svg", { class: "snap-track", viewBox: "0 0 100 8", preserveAspectRatio: "none", "aria-hidden": "true" },
112
+ svg("rect", { x: 0, y: 0, height: 8, width: Math.max(2, Math.round((entry.total / max) * 100)), class: "snap-fill" })),
113
+ el("span", { class: "num", text: money(entry.total, currency) }),
114
+ ]))),
115
+ capNote(capped),
116
+ );
117
+ } catch (error) {
118
+ node.replaceChildren(isDenied(error) ? errorBanner(error) : errorBanner(error, "Snapshot could not be computed"));
119
+ }
120
+ }
121
+
122
+ /** Custom Search: the "Open Invoices" saved-search view, five rows by due date with a link to the full list. */
123
+ export async function searchBody(node) {
124
+ if (!can("TRAN_CUSTINVC")) {
125
+ node.replaceChildren(el("p", { class: "muted", text: "The Invoice permission is required for this search." }));
126
+ return;
127
+ }
128
+ try {
129
+ const currency = app.session?.account?.baseCurrency ?? "";
130
+ const { rows, capped } = await loadInvoices('status IS "open"');
131
+ const sorted = rows.filter((row) => Number(row.amountRemaining) > 0)
132
+ .sort((a, b) => String(a.dueDate).localeCompare(String(b.dueDate)) || String(a.tranId).localeCompare(String(b.tranId)));
133
+ const shown = sorted.slice(0, 5);
134
+ if (shown.length === 0) { node.replaceChildren(emptyState("No results", "There are no open invoices.")); return; }
135
+ node.replaceChildren(
136
+ el("div", { class: "snap-title", text: "Open Invoices" }),
137
+ el("table", { class: "search-results" }, [
138
+ el("thead", {}, el("tr", {}, ["Date", "Number", "Name", "Due Date", "Amount Remaining"].map((h) => el("th", { text: h })))),
139
+ el("tbody", {}, shown.map((row) => el("tr", {}, [
140
+ el("td", { text: mdy(row.tranDate) }),
141
+ el("td", {}, el("a", { href: `#/invoices/${encodeURIComponent(row.id)}`, text: row.tranId ?? "" })),
142
+ el("td", { text: row.entity?.refName ?? "" }),
143
+ el("td", { class: app.today && String(row.dueDate) < app.today ? "overdue" : "", text: mdy(row.dueDate) }),
144
+ el("td", { class: "num", text: money(row.amountRemaining, currency) }),
145
+ ]))),
146
+ ]),
147
+ el("p", { class: "muted" }, [`${shown.length} of ${sorted.length} result${sorted.length === 1 ? "" : "s"} · `, el("a", { href: "#/invoices?tab=Open", text: "View all" })]),
148
+ capNote(capped),
149
+ );
150
+ } catch (error) {
151
+ node.replaceChildren(isDenied(error) ? errorBanner(error) : errorBanner(error, "Search could not be run"));
152
+ }
153
+ }