Files
Battle-for-Tismo/client/js/modals/budget.js
T

301 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The budget table shared by the nation and city panels. Every row is an item
// and its daily amount, income in green and every cost in red, with the
// projected total on the last row. Building types are rows that expand to their
// per-city cost. `breakdown` mirrors the shape both the national and the
// regional economy use, so one renderer serves them both.
import { percent, signedMoney, money, unitMoney, SOURCE_LABELS } from "./format.js";
import { BUDGET_EXPENSE_CATEGORIES, resourceById } from "../../../shared/data.js";
import { groupDigits } from "../../../shared/text_format.js";
import { resourceIcon } from "../ui/card.js";
import { currencySymbol } from "../currency.js";
// Draws the table into `$list`. `expanded` is the set of open building types,
// mutated in place; `onToggle` is called after a caret flips so the caller can
// redraw. Regions pass `showUnits: false`: unit upkeep is funded nationally.
export function renderBudget($list, breakdown = {}, { expanded = new Set(), onToggle = null, showUnits = true } = {}) {
if (!$list || !$list.length) return;
const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [], regions: [] };
const incomeSources = income.sources || [];
const building = breakdown.buildingUpkeep || { total: 0, groups: [], sources: [] };
const buildingGroups = building.groups || [];
const units = breakdown.unitUpkeep || { total: 0, groups: [], sources: [] };
const unitSources = units.sources || [];
const campaigns = breakdown.campaignUpkeep || { total: 0, sources: [] };
const campaignSources = campaigns.sources || [];
const transport = breakdown.transportUpkeep || { total: 0, sources: [] };
const transportSources = transport.sources || [];
const resourceMoney = breakdown.resourceMoney || [];
const net = breakdown.net || 0;
$list.empty();
const $body = $("<tbody></tbody>");
$body.append(sectionRow("Income"));
$body.append(moneyRow("Trade taxes", income.total, "income", null));
if (incomeSources.length === 0) {
$body.append(detailRow("No taxes collected", 0, "income-detail"));
}
for (const source of incomeSources) {
const where = source.cityName ? ` in ${source.cityName}` : "";
// Tax sources carry an amount but no percentage effect; older bonus sources
// still name their kind and effect.
const detail = source.effect === undefined
? `${source.label}${where}`
: `${source.label}${where} (${SOURCE_LABELS[source.kind] || source.kind}) ${percent(source.effect)}`;
$body.append(detailRow(detail, source.amount, "income-detail"));
}
$body.append(sectionRow("Expenses"));
if (buildingGroups.length === 0) {
$body.append(detailRow("No buildings", 0, "expense-detail"));
}
for (const group of buildingGroups) {
const key = `building:${group.index}`;
const isExpanded = expanded.has(key);
$body.append(groupRow(group.label, -group.amount, key, isExpanded, expanded, onToggle));
if (isExpanded) {
for (const city of group.cities) {
$body.append(
detailRow(
`${group.label} in ${city.cityName} — level ${city.level}`,
-city.amount,
"expense-detail"
)
);
}
}
}
if (showUnits) {
if (unitSources.length === 0) {
$body.append(detailRow("No units", 0, "expense-detail"));
}
for (const source of unitSources) {
$body.append(
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
);
}
for (const source of campaignSources) {
$body.append(
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
);
}
for (const source of transportSources) {
$body.append(
detailRow(`${source.label} ×${source.count}`, -source.amount, "expense-detail")
);
}
}
// What the nation spent buying each resource, and earned selling it to other
// countries, over the last day, shown as a daily figure. The record holds the
// day's total, so it is divided back to an hourly rate for `signedMoney`.
if (resourceMoney.length > 0) {
$body.append(sectionRow("Resource trade"));
for (const entry of resourceMoney) {
const proto = resourceById(entry.id);
const name = proto ? proto.name : entry.id;
if (entry.bought > 0) {
$body.append(detailRow(`Bought ${name}`, -entry.bought / 24, "expense-detail"));
}
if (entry.sold > 0) {
$body.append(detailRow(`Sold ${name}`, entry.sold / 24, "income-detail"));
}
}
}
// The real resource bills of the last day, as daily figures, in their own
// section so they are not confused with the forecast upkeep rows above.
const resourceCosts = breakdown.resourceCosts || [];
if (resourceCosts.length > 0) {
$body.append(sectionRow("Resource costs"));
for (const entry of resourceCosts) {
$body.append(detailRow(entry.label, -entry.amount, "expense-detail"));
}
}
const $table = $("<table class='budget-table'></table>");
$table.append($body);
$table.append($("<tfoot></tfoot>").append(totalRow("TOTAL", net)));
$list.append($table);
}
// Renders a whole month's actual budget: the tax and resource sales earned, the
// money each expense category spent, and the resource-trade memo. `report` is
// the compact shape the snapshot ships (category ids, not labels). The cash
// spent is the headline; when a category consumed materials worth more than the
// cash it paid (the stores covered the rest), the row notes the full market
// value. The treasury itself lives on the panel's subtitle, not in the table.
export function renderMonthlyBudget($list, report, { expanded = new Set(), onToggle = null } = {}) {
if (!$list || !$list.length) return;
$list.empty();
if (!report) return;
const $body = $("<tbody></tbody>");
$body.append(sectionRow("Income"));
const taxes = report.taxes || { sales: 0, export: 0, import: 0 };
$body.append(monthRow("National sales tax", taxes.sales, "income", null));
$body.append(monthRow("Export tariffs", taxes.export, "income", null));
$body.append(monthRow("Import tariffs", taxes.import, "income", null));
if (report.sales > 0) {
$body.append(monthRow("Resource sales", report.sales, "income-detail", null));
}
$body.append(sectionRow("Expenses"));
const byId = new Map((report.expenses || []).map((entry) => [entry[0], entry]));
const sections = [];
for (const category of BUDGET_EXPENSE_CATEGORIES) {
const entry = byId.get(category.id);
if (!entry) continue;
const last = sections[sections.length - 1];
if (!last || last.group !== category.group) {
sections.push({ group: category.group, rows: [] });
}
sections[sections.length - 1].rows.push({
id: category.id,
label: category.label,
cash: entry[1],
economic: entry[2],
buys: entry[3] || [],
});
}
for (const section of sections) {
$body.append(subsectionRow(section.group));
for (const row of section.rows) {
// The rightmost column is the month's true material cost. When the stores
// covered part of it only the smaller cash figure left the treasury, so
// that is noted under the name. Expanding names each resource bought and
// the buildings, units or works that consumed it.
const valued = row.economic > row.cash + 1;
const note = valued ? `cash ${money(-row.cash)}` : null;
const isOpen = expanded.has(row.id);
const expandable = row.buys.length > 0;
const $tr = monthRow(row.label, -row.economic, "expense-detail", note);
if (expandable) {
const $name = $tr.find(".budget-name");
$name.addClass("clickable");
$name.prepend($("<span class='budget-caret'></span>").text(isOpen ? "▾" : "▸"));
$name.on("click", () => {
if (expanded.has(row.id)) expanded.delete(row.id);
else expanded.add(row.id);
if (onToggle) onToggle(row.id);
});
}
$body.append($tr);
if (isOpen) {
for (const buy of row.buys) {
const [resource, amount, sources, quantity = 0] = buy;
const proto = resourceById(resource);
const name = proto ? proto.name : resource;
const unit = proto ? proto.unit : "";
// The money is the total bill; the quantity and the average delivered
// price the money bought it at is what shows the cost of the work.
const $label = $("<span class='res'></span>");
$label.append(resourceIcon(resource));
if (quantity > 0) {
$label.append(document.createTextNode(
`${groupDigits(Math.round(quantity))}${unit ? ` ${unit}` : ""}` +
` @ ${unitMoney(amount / quantity)}${unit ? `/${unit}` : ""} avg`
));
} else {
$label.append(document.createTextNode(name));
}
$body.append(monthRow($label, -amount, "expense-detail budget-buy", null));
for (const [source, sourceAmount] of sources || []) {
$body.append(
monthRow(source, -sourceAmount, "expense-detail budget-source", null)
);
}
}
}
}
}
const trade = report.resourceTrade || [];
if (trade.length > 0) {
$body.append(sectionRow("Resource trade"));
for (const [id, bought, sold] of trade) {
const proto = resourceById(id);
const name = proto ? proto.name : id;
if (bought > 0) $body.append(monthRow(`Bought ${name}`, -bought, "expense-detail", null));
if (sold > 0) $body.append(monthRow(`Sold ${name}`, sold, "income-detail", null));
}
}
const totalNote = report.totalEconomic > report.totalCash + 1
? `materials valued at ${currencySymbol()}${groupDigits(Math.round(report.totalEconomic))}`
: null;
const $table = $("<table class='budget-table'></table>");
$table.append($body);
$table.append($("<tfoot></tfoot>").append(monthTotalRow("TOTAL", report.net, totalNote)));
$list.append($table);
}
function monthRow(label, value, cls, note) {
const $tr = $("<tr></tr>").addClass(cls);
const $name = $("<td class='budget-name'></td>");
if (typeof label === "string") $name.text(label);
else $name.append(label);
if (note) $name.append($("<div class='budget-note'></div>").text(note));
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
return $tr;
}
function monthTotalRow(label, value, note) {
const $tr = $("<tr class='budget-total'></tr>");
const $name = $("<td class='budget-name'></td>").text(label);
if (note) $name.append($("<div class='budget-note'></div>").text(note));
$tr.append($name, $("<td class='budget-value'></td>").text(money(value)));
return $tr;
}
function subsectionRow(label) {
return $("<tr class='budget-subsection'></tr>").append(
$("<td colspan='2'></td>").text(label)
);
}
function sectionRow(label) {
return $("<tr class='budget-section'></tr>").append(
$("<td colspan='2'></td>").text(label)
);
}
function moneyRow(label, value, cls, note) {
const $tr = $("<tr></tr>").addClass(cls);
const $name = $("<td class='budget-name'></td>").text(label);
if (note) $name.append($("<div class='budget-note'></div>").text(note));
$tr.append($name, $("<td class='budget-value'></td>").text(signedMoney(value)));
return $tr;
}
function detailRow(label, value, cls) {
const $tr = $("<tr></tr>").addClass(cls);
$tr.append($("<td class='budget-name'></td>").text(label));
$tr.append(
$("<td class='budget-value'></td>").text(value === 0 ? "—" : signedMoney(value))
);
return $tr;
}
function groupRow(label, value, key, isExpanded, expanded, onToggle) {
const $tr = $("<tr class='budget-group'></tr>");
const $name = $("<td class='budget-name clickable'></td>");
$name.append($("<span class='budget-caret'></span>").text(isExpanded ? "▾" : "▸"));
$name.append(document.createTextNode(` ${label}`));
$name.on("click", () => {
if (expanded.has(key)) expanded.delete(key);
else expanded.add(key);
if (onToggle) onToggle(key);
});
$tr.append($name, $("<td class='budget-value'></td>").text(signedMoney(value)));
return $tr;
}
function totalRow(label, value) {
const $tr = $("<tr class='budget-total'></tr>");
$tr.append($("<td class='budget-name'></td>").text(label));
$tr.append($("<td class='budget-value'></td>").text(signedMoney(value)));
return $tr;
}