@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,162 @@
|
|
|
1
|
+
// Home dashboard: the NetSuite portlet grid (Reminders, Recent Records, Navigation, Tasks | Key Performance
|
|
2
|
+
// Indicators, Trend Graphs, Report Snapshots, Settings | Shortcuts, Custom Search, Calendar, Phone Calls, Tips).
|
|
3
|
+
// Every number is computed from this Tool's own record collections; portlets whose feature is outside the
|
|
4
|
+
// Tool's scope say so instead of showing invented rows.
|
|
5
|
+
import { app, can, listIds, expand, register } from "../store.js";
|
|
6
|
+
import { el, icon, money, addDays, errorBanner, emptyState, describe, isDenied } from "../ui.js";
|
|
7
|
+
import { notSimulated } from "../overlay.js";
|
|
8
|
+
import { setPageTitle, nsButton } from "./common.js";
|
|
9
|
+
import { MAX_KPI_ROWS, trendBody, snapshotBody, searchBody } from "./home-data.js";
|
|
10
|
+
import { navigationBody, calendarBody, activityBody } from "./home-chrome.js";
|
|
11
|
+
|
|
12
|
+
function portlet(title, body, { menu = true } = {}) {
|
|
13
|
+
return el("section", { class: "portlet" }, [
|
|
14
|
+
el("header", {}, [
|
|
15
|
+
el("h2", { text: title }),
|
|
16
|
+
el("span", { class: "phead-actions" }, menu
|
|
17
|
+
? [el("button", { type: "button", class: "icon-btn", "aria-label": `${title} portlet menu`, onclick: () => notSimulated(`${title} portlet setup`) }, icon("chevron", 14))]
|
|
18
|
+
: []),
|
|
19
|
+
]),
|
|
20
|
+
el("div", { class: "pbody" }, body),
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loading() { return el("div", { class: "muted", text: "Loading…" }); }
|
|
25
|
+
|
|
26
|
+
async function countOf(operationId, q) {
|
|
27
|
+
const page = await listIds(operationId, { q, limit: 10, offset: 0 });
|
|
28
|
+
return page.totalResults;
|
|
29
|
+
}
|
|
30
|
+
/** Sum a field over a filtered collection, paging instead of truncating; reports when the cap is reached. */
|
|
31
|
+
async function sumOf(listOperation, getOperation, q, field) {
|
|
32
|
+
let total = 0;
|
|
33
|
+
let counted = 0;
|
|
34
|
+
let capped = false;
|
|
35
|
+
for (let offset = 0; ; offset += 50) {
|
|
36
|
+
const page = await listIds(listOperation, { q, limit: 50, offset });
|
|
37
|
+
const rows = await expand(getOperation, page.ids);
|
|
38
|
+
for (const row of rows) total += Number(row[field]) || 0;
|
|
39
|
+
counted += rows.length;
|
|
40
|
+
if (!page.hasMore) break;
|
|
41
|
+
if (counted >= MAX_KPI_ROWS) { capped = true; break; }
|
|
42
|
+
}
|
|
43
|
+
return { total: Math.round(total * 100) / 100, counted, capped };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reminder(label, count, href, { alert = false } = {}) {
|
|
47
|
+
return el("div", { class: "reminder" }, [
|
|
48
|
+
el("span", { class: `rcount${count === 0 ? " zero" : alert && count > 0 ? " alert" : ""}`, text: String(count) }),
|
|
49
|
+
href && count > 0 ? el("a", { href, text: label }) : el("span", { class: count === 0 ? "muted" : "", text: label }),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function remindersBody(node) {
|
|
54
|
+
try {
|
|
55
|
+
const soon = addDays(app.today, 7);
|
|
56
|
+
const [toApprove, toFulfill, toBill, overdue, dueSoon, invToApprove, toDeposit] = await Promise.all([
|
|
57
|
+
can("TRAN_SALESORD") ? countOf("sales-order.list", 'status IS "pendingApproval"') : null,
|
|
58
|
+
can("TRAN_SALESORD") ? countOf("sales-order.list", 'status IS "pendingFulfillment"') : null,
|
|
59
|
+
can("TRAN_SALESORD") ? countOf("sales-order.list", '(status IS "pendingBilling" OR status IS "pendingBillingPartFulfilled")') : null,
|
|
60
|
+
can("TRAN_CUSTINVC") ? countOf("invoice.list", `status IS "open" AND dueDate BEFORE "${app.today}"`) : null,
|
|
61
|
+
can("TRAN_CUSTINVC") ? countOf("invoice.list", `status IS "open" AND dueDate ON_OR_AFTER "${app.today}" AND dueDate ON_OR_BEFORE "${soon}"`) : null,
|
|
62
|
+
can("TRAN_CUSTINVC") ? countOf("invoice.list", 'status IS "pendingApproval"') : null,
|
|
63
|
+
can("TRAN_CUSTPYMT") ? countOf("customer-payment.list", 'status IS "notDeposited"') : null,
|
|
64
|
+
]);
|
|
65
|
+
const rows = [];
|
|
66
|
+
if (toApprove !== null) rows.push(reminder("Sales Orders to Approve", toApprove, "#/orders?status=pendingApproval"));
|
|
67
|
+
if (toFulfill !== null) rows.push(reminder("Sales Orders to Fulfill", toFulfill, "#/orders?status=pendingFulfillment"));
|
|
68
|
+
if (toBill !== null) rows.push(reminder("Sales Orders to Bill", toBill, "#/orders?status=pendingBilling"));
|
|
69
|
+
if (invToApprove !== null) rows.push(reminder("Invoices to Approve", invToApprove, "#/invoices?tab=All"));
|
|
70
|
+
if (overdue !== null) rows.push(reminder("Overdue Invoices", overdue, "#/invoices?tab=Overdue", { alert: true }));
|
|
71
|
+
if (dueSoon !== null) rows.push(reminder("Invoices Due Within 7 Days", dueSoon, "#/invoices?tab=Open"));
|
|
72
|
+
if (toDeposit !== null) rows.push(reminder("Payments to Deposit", toDeposit, "#/payments"));
|
|
73
|
+
node.replaceChildren(...(rows.length ? rows : [emptyState("No reminders", "This role cannot view transactions.")]));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
node.replaceChildren(errorBanner(error, "Reminders could not be loaded"));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function kpiBody(node) {
|
|
80
|
+
if (!can("TRAN_CUSTINVC")) {
|
|
81
|
+
node.replaceChildren(el("div", { class: "muted", text: describe(new Error("The Invoice permission is required for these indicators.")) }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const currency = app.session?.account?.baseCurrency ?? "";
|
|
86
|
+
const open = await sumOf("invoice.list", "invoice.get", 'status IS "open"', "amountRemaining");
|
|
87
|
+
const overdue = await sumOf("invoice.list", "invoice.get", `status IS "open" AND dueDate BEFORE "${app.today}"`, "amountRemaining");
|
|
88
|
+
const unbilled = can("TRAN_SALESORD")
|
|
89
|
+
? await sumOf("sales-order.list", "sales-order.get", '(status IS "pendingBilling" OR status IS "pendingBillingPartFulfilled" OR status IS "pendingFulfillment")', "total")
|
|
90
|
+
: null;
|
|
91
|
+
const table = el("table", { class: "kv" }, [
|
|
92
|
+
el("tr", {}, [el("td", { text: "Open Invoice Total" }), el("td", { class: "num", text: money(open.total, currency) })]),
|
|
93
|
+
el("tr", {}, [el("td", { text: "Overdue Receivables" }), el("td", { class: "num", text: money(overdue.total, currency) })]),
|
|
94
|
+
unbilled ? el("tr", {}, [el("td", { text: "Unbilled Orders" }), el("td", { class: "num", text: money(unbilled.total, currency) })]) : null,
|
|
95
|
+
]);
|
|
96
|
+
const capped = [open, overdue, unbilled].some((result) => result?.capped === true);
|
|
97
|
+
node.replaceChildren(table, capped
|
|
98
|
+
? el("p", { class: "muted", text: `Computed over the first ${MAX_KPI_ROWS} matching transactions.` })
|
|
99
|
+
: el("p", { class: "muted", text: `As of ${app.today}, the account date of this world.` }));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
node.replaceChildren(isDenied(error) ? errorBanner(error) : errorBanner(error, "Indicators could not be computed"));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function recentBody() {
|
|
106
|
+
if (app.recent.length === 0) {
|
|
107
|
+
return el("p", { class: "muted", text: "Records you open appear here." });
|
|
108
|
+
}
|
|
109
|
+
return el("ul", { class: "shortcut-list" }, app.recent.map((entry) =>
|
|
110
|
+
el("li", {}, el("a", { href: entry.href, text: entry.label }))));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shortcutsBody() {
|
|
114
|
+
const entries = [
|
|
115
|
+
["Customers", "#/customers", can("LIST_CUSTJOB")],
|
|
116
|
+
["Sales Orders", "#/orders", can("TRAN_SALESORD")],
|
|
117
|
+
["Invoices", "#/invoices", can("TRAN_CUSTINVC")],
|
|
118
|
+
["Customer Payments", "#/payments", can("TRAN_CUSTPYMT")],
|
|
119
|
+
["Items", "#/items", can("LIST_ITEM")],
|
|
120
|
+
["SuiteQL Query Tool", "#/suiteql", can("REPO_ANALYTICS")],
|
|
121
|
+
].filter(([, , allowed]) => allowed);
|
|
122
|
+
return el("ul", { class: "shortcut-list" }, entries.map(([label, href]) => el("li", {}, el("a", { href, text: label }))));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
register("home", async (main) => {
|
|
126
|
+
setPageTitle("Home");
|
|
127
|
+
const session = app.session;
|
|
128
|
+
const reminders = el("div", {}, loading());
|
|
129
|
+
const kpis = el("div", {}, loading());
|
|
130
|
+
const trend = el("div", {}, loading());
|
|
131
|
+
const snapshot = el("div", {}, loading());
|
|
132
|
+
const search = el("div", {}, loading());
|
|
133
|
+
main.replaceChildren(el("div", { class: "dash" }, [
|
|
134
|
+
el("div", { class: "dash-title" }, [
|
|
135
|
+
el("h1", { text: session ? `${session.account.companyName} — Home` : "Home" }),
|
|
136
|
+
el("span", { class: "muted", text: session ? `${session.user.name} · ${session.role.name} · account date ${app.today}` : "" }),
|
|
137
|
+
]),
|
|
138
|
+
el("div", { class: "col" }, [
|
|
139
|
+
portlet("Reminders", reminders),
|
|
140
|
+
portlet("Recent Records", recentBody()),
|
|
141
|
+
portlet("Navigation", navigationBody()),
|
|
142
|
+
portlet("Tasks", activityBody("Tasks")),
|
|
143
|
+
]),
|
|
144
|
+
el("div", { class: "col" }, [
|
|
145
|
+
portlet("Key Performance Indicators", kpis),
|
|
146
|
+
portlet("Trend Graphs", trend),
|
|
147
|
+
portlet("Report Snapshots", snapshot),
|
|
148
|
+
portlet("Settings", [
|
|
149
|
+
el("p", { class: "muted", text: "Personalization of the dashboard is not simulated by this Tool." }),
|
|
150
|
+
el("div", { class: "btn-row" }, [nsButton("Personalize Dashboard"), nsButton("Layout")]),
|
|
151
|
+
], { menu: false }),
|
|
152
|
+
]),
|
|
153
|
+
el("div", { class: "col" }, [
|
|
154
|
+
portlet("Shortcuts", shortcutsBody()),
|
|
155
|
+
portlet("Custom Search", search),
|
|
156
|
+
portlet("Calendar", calendarBody()),
|
|
157
|
+
portlet("Phone Calls", activityBody("Phone Calls")),
|
|
158
|
+
portlet("Tips", el("p", { class: "muted", text: "This is a synthetic NetSuite account served by a Firedrill Tool. Records written here are the same records your agent reads over SuiteTalk REST." }), { menu: false }),
|
|
159
|
+
]),
|
|
160
|
+
]));
|
|
161
|
+
await Promise.all([remindersBody(reminders), kpiBody(kpis), trendBody(trend), snapshotBody(snapshot), searchBody(search)]);
|
|
162
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Transactions → Sales → Create Invoices: a standalone invoice entry form (an invoice billed from a sales
|
|
2
|
+
// order is created by the Bill action on the order instead). Save calls invoice.create.
|
|
3
|
+
import { app, can, go, register } from "../store.js";
|
|
4
|
+
import { call, el, errorBanner, money, mdy, newKey, toast, ToolError } from "../ui.js";
|
|
5
|
+
import { recordShell, setPageTitle, toolbarButton, nsButton, totalsBlock, fieldGroup } from "./common.js";
|
|
6
|
+
import { pickCustomer, pickItem } from "./pickers.js";
|
|
7
|
+
|
|
8
|
+
register("invoice-new", async (main) => {
|
|
9
|
+
setPageTitle("Invoice");
|
|
10
|
+
if (!can("TRAN_CUSTINVC", "create")) {
|
|
11
|
+
main.replaceChildren(errorBanner(new Error("Your role cannot create invoices."), "Permission Violation"));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const currency = app.session?.account?.baseCurrency ?? "";
|
|
15
|
+
const state = { customer: null, lines: [], key: newKey(), saving: false };
|
|
16
|
+
const errorHost = el("div", {});
|
|
17
|
+
const linesBody = el("tbody", {});
|
|
18
|
+
const totalsHost = el("div", {});
|
|
19
|
+
|
|
20
|
+
const customerLabel = el("span", { class: "muted", text: "No customer selected" });
|
|
21
|
+
const customerButton = toolbarButton("Select Customer…", async () => {
|
|
22
|
+
const chosen = await pickCustomer();
|
|
23
|
+
if (!chosen) return;
|
|
24
|
+
state.customer = chosen;
|
|
25
|
+
customerLabel.textContent = `${chosen.entityId} · ${chosen.subsidiary?.refName ?? ""}`;
|
|
26
|
+
customerLabel.className = "";
|
|
27
|
+
app.dirty = true;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const dateInput = el("input", { type: "date", id: "inv-date", value: app.today });
|
|
31
|
+
const dueInput = el("input", { type: "date", id: "inv-due" });
|
|
32
|
+
const memoInput = el("input", { type: "text", id: "inv-memo-new", maxlength: "999" });
|
|
33
|
+
for (const control of [dateInput, dueInput, memoInput]) control.addEventListener("input", () => { app.dirty = true; });
|
|
34
|
+
|
|
35
|
+
function recalc() {
|
|
36
|
+
const subtotal = state.lines.reduce((sum, line) => sum + (Number(line.quantity) || 0) * (Number(line.rate) || 0), 0);
|
|
37
|
+
totalsHost.replaceChildren(totalsBlock([
|
|
38
|
+
["Subtotal", money(subtotal, currency)],
|
|
39
|
+
["Tax", "calculated on save"],
|
|
40
|
+
["Estimated Total", money(subtotal, currency), true],
|
|
41
|
+
]));
|
|
42
|
+
}
|
|
43
|
+
function paintLines() {
|
|
44
|
+
if (state.lines.length === 0) {
|
|
45
|
+
linesBody.replaceChildren(el("tr", {}, el("td", { colspan: "6", class: "muted", text: "No lines yet. Add an item to this invoice." })));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
linesBody.replaceChildren(...state.lines.map((line, index) => {
|
|
49
|
+
const quantityId = `inv-line-${index}-qty`;
|
|
50
|
+
const rateId = `inv-line-${index}-rate`;
|
|
51
|
+
const quantityInput = el("input", { type: "number", id: quantityId, min: "0", step: "1", value: String(line.quantity) });
|
|
52
|
+
const rateInput = el("input", { type: "number", id: rateId, min: "0", step: "0.01", value: String(line.rate) });
|
|
53
|
+
quantityInput.addEventListener("input", () => { line.quantity = Number(quantityInput.value); app.dirty = true; recalc(); });
|
|
54
|
+
rateInput.addEventListener("input", () => { line.rate = Number(rateInput.value); app.dirty = true; recalc(); });
|
|
55
|
+
return el("tr", {}, [
|
|
56
|
+
el("td", { class: "tight num", text: String(index + 1) }),
|
|
57
|
+
el("td", { text: line.label }),
|
|
58
|
+
el("td", { class: "num" }, [el("label", { class: "visually-hidden", for: quantityId, text: `Quantity for line ${index + 1}` }), quantityInput]),
|
|
59
|
+
el("td", { class: "num" }, [el("label", { class: "visually-hidden", for: rateId, text: `Rate for line ${index + 1}` }), rateInput]),
|
|
60
|
+
el("td", { class: "num", text: money((Number(line.quantity) || 0) * (Number(line.rate) || 0), currency) }),
|
|
61
|
+
el("td", {}, el("button", { type: "button", class: "btn", text: "Remove", onclick: () => { state.lines.splice(index, 1); paintLines(); recalc(); } })),
|
|
62
|
+
]);
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function save() {
|
|
67
|
+
if (state.saving) return;
|
|
68
|
+
errorHost.replaceChildren();
|
|
69
|
+
if (state.customer === null) { errorHost.replaceChildren(errorBanner(new Error("Please choose a customer for this invoice."), "Customer is required")); return; }
|
|
70
|
+
if (state.lines.length === 0) { errorHost.replaceChildren(errorBanner(new Error("Please enter at least one line item for this transaction."), "Items are required")); return; }
|
|
71
|
+
state.saving = true;
|
|
72
|
+
try {
|
|
73
|
+
const body = {
|
|
74
|
+
entity: { id: state.customer.id },
|
|
75
|
+
tranDate: dateInput.value,
|
|
76
|
+
item: { items: state.lines.map((line) => ({ item: { id: line.itemId }, quantity: Number(line.quantity), rate: Number(line.rate) })) },
|
|
77
|
+
};
|
|
78
|
+
if (dueInput.value !== "") body.dueDate = dueInput.value;
|
|
79
|
+
if (memoInput.value.trim() !== "") body.memo = memoInput.value.trim();
|
|
80
|
+
const result = await call("invoice.create", { body }, state.key);
|
|
81
|
+
app.dirty = false;
|
|
82
|
+
toast(`Invoice created (internal id ${result.id}).`);
|
|
83
|
+
go(`#/invoices/${encodeURIComponent(result.id)}`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
state.key = newKey();
|
|
86
|
+
const path = error instanceof ToolError ? error.details?.errorPath : undefined;
|
|
87
|
+
errorHost.replaceChildren(errorBanner(error, "This invoice could not be saved"), ...(path ? [el("p", { class: "field-error", text: `Field: ${path}` })] : []));
|
|
88
|
+
} finally {
|
|
89
|
+
state.saving = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main.replaceChildren(recordShell({
|
|
94
|
+
title: "Invoice",
|
|
95
|
+
subtitle: `New · ${app.session?.account?.companyName ?? ""} · ${mdy(app.today)}`,
|
|
96
|
+
actions: [
|
|
97
|
+
toolbarButton("Save", save, { kind: "primary" }),
|
|
98
|
+
toolbarButton("Cancel", () => { app.dirty = false; go("#/invoices"); }),
|
|
99
|
+
nsButton("Save & Print"),
|
|
100
|
+
nsButton("Reset"),
|
|
101
|
+
],
|
|
102
|
+
body: [
|
|
103
|
+
errorHost,
|
|
104
|
+
fieldGroup("Primary Information", el("div", { class: "formgrid" }, [
|
|
105
|
+
el("div", { class: "field" }, [el("span", { class: "fl" }, "Customer"), el("div", { class: "btn-row" }, [customerButton, customerLabel])]),
|
|
106
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "inv-date", text: "Date" }), dateInput]),
|
|
107
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "inv-due", text: "Due Date" }), dueInput]),
|
|
108
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "inv-memo-new", text: "Memo" }), memoInput]),
|
|
109
|
+
])),
|
|
110
|
+
el("section", { class: "sublist" }, [
|
|
111
|
+
el("header", {}, [el("h3", { text: "Items" }), el("span", { class: "grow" }),
|
|
112
|
+
toolbarButton("Add Item", async () => {
|
|
113
|
+
const item = await pickItem();
|
|
114
|
+
if (!item) return;
|
|
115
|
+
state.lines.push({ itemId: item.id, label: `${item.itemId}${item.displayName ? ` — ${item.displayName}` : ""}`, quantity: 1, rate: Number(item.basePrice) || 0 });
|
|
116
|
+
app.dirty = true;
|
|
117
|
+
paintLines();
|
|
118
|
+
recalc();
|
|
119
|
+
})]),
|
|
120
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid linegrid" }, [
|
|
121
|
+
el("thead", {}, el("tr", {}, ["Line", "Item", "Quantity", "Rate", "Amount", ""].map((label, index) =>
|
|
122
|
+
el("th", { scope: "col", class: index >= 2 && index <= 4 ? "num" : "", text: label })))),
|
|
123
|
+
linesBody,
|
|
124
|
+
])),
|
|
125
|
+
]),
|
|
126
|
+
totalsHost,
|
|
127
|
+
],
|
|
128
|
+
}));
|
|
129
|
+
paintLines();
|
|
130
|
+
recalc();
|
|
131
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The invoice record page: totals, the Items sublist, applied payments and the Accept Payment action,
|
|
2
|
+
// which runs the !transform to a customer payment.
|
|
3
|
+
import { app, can, go, listIds, expand, register, remember, isOverdue } 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, pill, currencyOf,
|
|
9
|
+
} from "./common.js";
|
|
10
|
+
|
|
11
|
+
const TABS = ["Items", "Payments", "System Information"];
|
|
12
|
+
|
|
13
|
+
function openPaymentDialog(record, currency, refresh) {
|
|
14
|
+
const remaining = Number(record.amountRemaining) || 0;
|
|
15
|
+
const amountInput = el("input", { type: "number", id: "pay-amount", min: "0", step: "0.01", max: String(remaining), value: remaining.toFixed(2) });
|
|
16
|
+
const dateInput = el("input", { type: "date", id: "pay-date", value: app.today });
|
|
17
|
+
const memoInput = el("input", { type: "text", id: "pay-memo", maxlength: "999" });
|
|
18
|
+
const errorHost = el("div", {});
|
|
19
|
+
let key = newKey();
|
|
20
|
+
let busy = false;
|
|
21
|
+
dialog({
|
|
22
|
+
title: `Accept Customer Payment — ${record.tranId}`,
|
|
23
|
+
body: [
|
|
24
|
+
errorHost,
|
|
25
|
+
el("p", { text: `${record.entity?.refName ?? "This customer"} owes ${money(remaining, currency)} on this invoice.` }),
|
|
26
|
+
el("div", { class: "formgrid" }, [
|
|
27
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "pay-amount", text: "Payment Amount" }), amountInput]),
|
|
28
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "pay-date", text: "Date" }), dateInput]),
|
|
29
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "pay-memo", text: "Memo" }), memoInput]),
|
|
30
|
+
]),
|
|
31
|
+
],
|
|
32
|
+
actions: [
|
|
33
|
+
{ label: "Cancel", run: (close) => close() },
|
|
34
|
+
{
|
|
35
|
+
label: "Save Payment",
|
|
36
|
+
kind: "primary",
|
|
37
|
+
run: async (close) => {
|
|
38
|
+
if (busy) return;
|
|
39
|
+
busy = true;
|
|
40
|
+
errorHost.replaceChildren();
|
|
41
|
+
const amount = Number(amountInput.value);
|
|
42
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
43
|
+
errorHost.replaceChildren(errorBanner(new Error("The payment amount must be greater than zero."), "Invalid amount"));
|
|
44
|
+
busy = false;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (amount > remaining + 0.005) {
|
|
48
|
+
errorHost.replaceChildren(errorBanner(new Error(`The applied amount cannot exceed the invoice balance of ${remaining}.`), "Amount too large"));
|
|
49
|
+
busy = false;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const body = { payment: amount, tranDate: dateInput.value };
|
|
54
|
+
if (memoInput.value.trim() !== "") body.memo = memoInput.value.trim();
|
|
55
|
+
const result = await call("invoice.transform", {
|
|
56
|
+
recordId: record.id, target: "customerPayment", transformMarker: "!transform", body,
|
|
57
|
+
}, key);
|
|
58
|
+
toast(`Customer payment recorded (internal id ${result.id}).`);
|
|
59
|
+
close();
|
|
60
|
+
refresh();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
key = newKey();
|
|
63
|
+
errorHost.replaceChildren(errorBanner(error, "This payment could not be recorded"));
|
|
64
|
+
} finally {
|
|
65
|
+
busy = false;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function paymentsPanel(node, record, currency) {
|
|
74
|
+
node.replaceChildren(el("p", { class: "muted", text: "Loading payments…" }));
|
|
75
|
+
try {
|
|
76
|
+
const page = await listIds("customer-payment.list", { q: `customer ANY_OF [${record.entity?.id ?? 0}]`, limit: 25, offset: 0 });
|
|
77
|
+
const rows = await expand("customer-payment.get", page.ids, { expandSubResources: true });
|
|
78
|
+
const applied = rows.filter((payment) => (payment.apply?.items ?? []).some((entry) => String(entry.doc) === String(record.id)));
|
|
79
|
+
node.replaceChildren(el("section", { class: "sublist" }, [
|
|
80
|
+
el("header", {}, [el("h3", { text: "Payments Applied" })]),
|
|
81
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
|
|
82
|
+
el("thead", {}, el("tr", {}, ["Payment", "Date", "Applied", "Payment Total", "Status"].map((label, index) =>
|
|
83
|
+
el("th", { scope: "col", class: index === 2 || index === 3 ? "num" : "", text: label })))),
|
|
84
|
+
el("tbody", {}, applied.length === 0
|
|
85
|
+
? [el("tr", {}, el("td", { colspan: "5", class: "muted", text: "No payments have been applied to this invoice." }))]
|
|
86
|
+
: applied.map((payment) => {
|
|
87
|
+
const entry = (payment.apply?.items ?? []).find((item) => String(item.doc) === String(record.id));
|
|
88
|
+
return el("tr", {}, [
|
|
89
|
+
el("td", { text: payment.tranId }),
|
|
90
|
+
el("td", { class: "tight", text: mdy(payment.tranDate) }),
|
|
91
|
+
el("td", { class: "num", text: money(entry?.amount ?? 0, currency) }),
|
|
92
|
+
el("td", { class: "num", text: money(payment.payment, currency) }),
|
|
93
|
+
el("td", {}, pill(payment.status)),
|
|
94
|
+
]);
|
|
95
|
+
})),
|
|
96
|
+
])),
|
|
97
|
+
]));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
node.replaceChildren(errorBanner(error, "Payments could not be loaded"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
register("invoice", async (main, route) => {
|
|
104
|
+
let record;
|
|
105
|
+
try {
|
|
106
|
+
record = await call("invoice.get", { recordId: route.id, expandSubResources: true });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
main.replaceChildren(errorBanner(error, "This invoice could not be opened"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
setPageTitle(`Invoice ${record.tranId}`);
|
|
112
|
+
remember({ label: `Invoice: ${record.tranId}`, href: `#/invoices/${encodeURIComponent(record.id)}` });
|
|
113
|
+
const currency = currencyOf(record);
|
|
114
|
+
const refresh = () => go(`#/invoices/${encodeURIComponent(record.id)}`);
|
|
115
|
+
let tab = TABS[0];
|
|
116
|
+
const panel = el("div", {});
|
|
117
|
+
|
|
118
|
+
function paint() {
|
|
119
|
+
if (tab === "Items") {
|
|
120
|
+
const lines = record.item?.items ?? [];
|
|
121
|
+
panel.replaceChildren(
|
|
122
|
+
el("section", { class: "sublist" }, [
|
|
123
|
+
el("header", {}, [el("h3", { text: "Items" }), el("span", { class: "grow" }), el("span", { class: "muted", text: `${lines.length} line${lines.length === 1 ? "" : "s"}` })]),
|
|
124
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
|
|
125
|
+
el("thead", {}, el("tr", {}, ["Line", "Item", "Description", "Quantity", "Rate", "Amount"].map((label, index) =>
|
|
126
|
+
el("th", { scope: "col", class: index >= 3 ? "num" : "", text: label })))),
|
|
127
|
+
el("tbody", {}, lines.map((line) => el("tr", {}, [
|
|
128
|
+
el("td", { class: "tight num", text: String(line.line) }),
|
|
129
|
+
el("td", { text: line.item?.refName ?? "" }),
|
|
130
|
+
el("td", { text: line.description ?? "" }),
|
|
131
|
+
el("td", { class: "num", text: qty(line.quantity) }),
|
|
132
|
+
el("td", { class: "num", text: money(line.rate, currency) }),
|
|
133
|
+
el("td", { class: "num", text: money(line.amount, currency) }),
|
|
134
|
+
]))),
|
|
135
|
+
])),
|
|
136
|
+
]),
|
|
137
|
+
totalsBlock([
|
|
138
|
+
["Subtotal", money(record.subtotal, currency)],
|
|
139
|
+
["Tax Total", money(record.taxTotal, currency)],
|
|
140
|
+
["Total", money(record.total, currency), true],
|
|
141
|
+
["Amount Paid", money(record.amountPaid, currency)],
|
|
142
|
+
["Amount Remaining", money(record.amountRemaining, currency), true],
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
} else if (tab === "Payments") {
|
|
146
|
+
paymentsPanel(panel, record, currency);
|
|
147
|
+
} else {
|
|
148
|
+
panel.replaceChildren(fieldGroup("System Information", kvColumns(
|
|
149
|
+
[kv("Internal ID", record.id), kv("Created", mdy(record.createdDate)), kv("Created From", record.createdFrom
|
|
150
|
+
? el("a", { href: `#/orders/${encodeURIComponent(record.createdFrom.id)}`, text: record.createdFrom.refName })
|
|
151
|
+
: "— None —")],
|
|
152
|
+
[kv("Last Modified", mdy(record.lastModifiedDate)), kv("Record Type", "invoice")],
|
|
153
|
+
)));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const canPay = can("TRAN_CUSTPYMT", "create") && String(record.status?.refName) === "Open" && Number(record.amountRemaining) > 0;
|
|
158
|
+
const actionsButton = el("button", { type: "button", class: "btn", "aria-haspopup": "true", "aria-expanded": "false", text: "Actions ▾" });
|
|
159
|
+
actionsButton.addEventListener("click", (event) => {
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
openMenu(actionsButton, [
|
|
162
|
+
{ label: "Accept Payment", run: () => openPaymentDialog(record, currency, refresh), disabled: !canPay },
|
|
163
|
+
{ label: "Void" },
|
|
164
|
+
{ label: "Make Copy" },
|
|
165
|
+
{ label: "Credit Memo" },
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
main.replaceChildren(recordShell({
|
|
170
|
+
title: `Invoice #${record.tranId}`,
|
|
171
|
+
subtitle: `${record.entity?.refName ?? ""} · ${mdy(record.tranDate)} · ${record.subsidiary?.refName ?? ""}`,
|
|
172
|
+
actions: recordButtons({
|
|
173
|
+
onEdit: () => openEdit(record, refresh),
|
|
174
|
+
editDisabled: !can("TRAN_CUSTINVC", "edit"),
|
|
175
|
+
onBack: () => go("#/invoices"),
|
|
176
|
+
actions: [
|
|
177
|
+
toolbarButton("Accept Payment", () => openPaymentDialog(record, currency, refresh), {
|
|
178
|
+
disabled: !canPay,
|
|
179
|
+
title: canPay ? "Record a customer payment against this invoice" : "This invoice has no remaining balance",
|
|
180
|
+
}),
|
|
181
|
+
actionsButton,
|
|
182
|
+
],
|
|
183
|
+
}),
|
|
184
|
+
subtabs: subtabStrip(TABS, tab, (next) => { tab = next; paint(); }),
|
|
185
|
+
body: [
|
|
186
|
+
isOverdue(record)
|
|
187
|
+
? el("div", { class: "banner warn", role: "status" }, el("div", {}, el("div", { class: "banner-title", text: `This invoice was due on ${mdy(record.dueDate)} and is overdue.` })))
|
|
188
|
+
: null,
|
|
189
|
+
el("div", { class: "fieldcols" }, [
|
|
190
|
+
el("dl", {}, [kv("Document Number", record.tranId), kv("Date", mdy(record.tranDate)), kv("Due Date", mdy(record.dueDate))]),
|
|
191
|
+
el("dl", {}, [kv("Customer", el("a", { href: `#/customers/${encodeURIComponent(record.entity?.id ?? "")}`, text: record.entity?.refName ?? "" })),
|
|
192
|
+
kv("Status", pill(record.status)), kv("Terms", record.terms?.refName ?? "— None —")]),
|
|
193
|
+
]),
|
|
194
|
+
panel,
|
|
195
|
+
],
|
|
196
|
+
}));
|
|
197
|
+
paint();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/** NetSuite lets an open invoice's memo, PO number and terms be edited; lines stay as billed. */
|
|
201
|
+
function openEdit(record, refresh) {
|
|
202
|
+
const memoInput = el("input", { type: "text", id: "inv-memo", value: record.memo ?? "", maxlength: "999" });
|
|
203
|
+
const poInput = el("input", { type: "text", id: "inv-po", value: record.otherRefNum ?? "", maxlength: "45" });
|
|
204
|
+
const errorHost = el("div", {});
|
|
205
|
+
let key = newKey();
|
|
206
|
+
dialog({
|
|
207
|
+
title: `Edit Invoice — ${record.tranId}`,
|
|
208
|
+
body: [errorHost, el("div", { class: "formgrid" }, [
|
|
209
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "inv-memo", text: "Memo" }), memoInput]),
|
|
210
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "inv-po", text: "PO / Check Number" }), poInput]),
|
|
211
|
+
])],
|
|
212
|
+
actions: [
|
|
213
|
+
{ label: "Cancel", run: (close) => close() },
|
|
214
|
+
{
|
|
215
|
+
label: "Save",
|
|
216
|
+
kind: "primary",
|
|
217
|
+
run: async (close) => {
|
|
218
|
+
errorHost.replaceChildren();
|
|
219
|
+
try {
|
|
220
|
+
await call("invoice.update", { recordId: record.id, body: { memo: memoInput.value.trim(), otherRefNum: poInput.value.trim() } }, key);
|
|
221
|
+
toast("Invoice saved.");
|
|
222
|
+
close();
|
|
223
|
+
refresh();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
key = newKey();
|
|
226
|
+
errorHost.replaceChildren(errorBanner(error, "This invoice could not be saved"));
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Transactions → Sales → Invoices List: Open / Paid In Full / Overdue / All views with paging.
|
|
2
|
+
import { app, can, go, listIds, expand, register, isOverdue } 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: "Due Date" }, { label: "Customer" },
|
|
8
|
+
{ label: "Status" }, { label: "Amount", numeric: true }, { label: "Amount Remaining", numeric: true },
|
|
9
|
+
];
|
|
10
|
+
const TABS = ["Open", "Overdue", "Paid In Full", "All"];
|
|
11
|
+
|
|
12
|
+
function filterFor(tab, term, today) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (tab === "Open") parts.push('status IS "open"');
|
|
15
|
+
if (tab === "Paid In Full") parts.push('status IS "paidInFull"');
|
|
16
|
+
if (tab === "Overdue") parts.push(`status IS "open" AND dueDate BEFORE ${lit(today)}`);
|
|
17
|
+
if (term !== "") parts.push(`(tranId CONTAIN ${lit(term)} OR memo CONTAIN ${lit(term)})`);
|
|
18
|
+
return parts.join(" AND ");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
register("invoices", async (main, route) => {
|
|
22
|
+
setPageTitle("Invoices");
|
|
23
|
+
const state = {
|
|
24
|
+
tab: TABS.includes(route.params.get("tab")) ? route.params.get("tab") : "Open",
|
|
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.tab !== "Open") params.set("tab", next.tab);
|
|
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(`#/invoices${query ? `?${query}` : ""}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tabs = el("div", { class: "tabstrip", role: "tablist" }, TABS.map((name) =>
|
|
44
|
+
el("button", { type: "button", role: "tab", "aria-selected": String(name === state.tab), text: name, onclick: () => apply({ tab: name, offset: 0 }) })));
|
|
45
|
+
const input = el("input", { type: "search", id: "inv-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: "inv-filter", text: "Quick Filter" }), input]),
|
|
48
|
+
toolbarButton("Search", () => apply({ term: input.value.trim(), offset: 0 })),
|
|
49
|
+
toolbarButton("Reset", () => apply({ term: "", offset: 0 })),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
main.replaceChildren(listShell({
|
|
53
|
+
title: "Invoices",
|
|
54
|
+
subtitle: app.session?.subsidiaryScope?.id !== "0" ? `Restricted to ${app.session.subsidiaryScope.refName}` : undefined,
|
|
55
|
+
actions: [
|
|
56
|
+
toolbarButton("New Invoice", () => go("#/invoices/new"), {
|
|
57
|
+
kind: "primary",
|
|
58
|
+
disabled: !can("TRAN_CUSTINVC", "create"),
|
|
59
|
+
title: can("TRAN_CUSTINVC", "create") ? undefined : "Your role cannot create invoices",
|
|
60
|
+
}),
|
|
61
|
+
nsButton("Customize View"),
|
|
62
|
+
nsButton("Export"),
|
|
63
|
+
],
|
|
64
|
+
tabs,
|
|
65
|
+
filter,
|
|
66
|
+
columns: COLUMNS,
|
|
67
|
+
body,
|
|
68
|
+
pager: pagerHost,
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const page = await listIds("invoice.list", { q: filterFor(state.tab, state.term, app.today), limit: state.size, offset: state.offset });
|
|
73
|
+
const rows = await expand("invoice.get", page.ids);
|
|
74
|
+
if (rows.length === 0) {
|
|
75
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) },
|
|
76
|
+
emptyState(`No ${state.tab.toLowerCase()} invoices.`, "Change the view or clear the quick filter."))));
|
|
77
|
+
} else {
|
|
78
|
+
body.replaceChildren(...rows.map((record) => {
|
|
79
|
+
const currency = currencyOf(record);
|
|
80
|
+
const late = isOverdue(record);
|
|
81
|
+
return el("tr", {}, [
|
|
82
|
+
el("td", {}, el("a", { class: "rowlink", href: `#/invoices/${encodeURIComponent(record.id)}`, text: record.tranId })),
|
|
83
|
+
el("td", { class: "tight", text: mdy(record.tranDate) }),
|
|
84
|
+
el("td", { class: `tight${late ? " overdue" : ""}`, text: mdy(record.dueDate) }),
|
|
85
|
+
el("td", {}, el("a", { href: `#/customers/${encodeURIComponent(record.entity?.id ?? "")}`, text: record.entity?.refName ?? "" })),
|
|
86
|
+
el("td", {}, pill(record.status)),
|
|
87
|
+
el("td", { class: "num", text: money(record.total, currency) }),
|
|
88
|
+
el("td", { class: `num${late ? " overdue" : ""}`, text: money(record.amountRemaining, currency) }),
|
|
89
|
+
]);
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
pagerHost.replaceChildren(pagerStrip({
|
|
93
|
+
offset: page.offset, shown: rows.length, total: page.totalResults, hasMore: page.hasMore, pageSize: state.size,
|
|
94
|
+
onOffset: (offset) => apply({ offset }), onPageSize: (size) => apply({ size, offset: 0 }),
|
|
95
|
+
}));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "The invoice list could not be loaded"))));
|
|
98
|
+
}
|
|
99
|
+
});
|