From 03ab88b5bbcb3325d0159c2263b2617f45bd8384 Mon Sep 17 00:00:00 2001 From: Yassine Oualid Date: Wed, 28 Aug 2024 18:54:18 +0100 Subject: [PATCH] First Draft Cost Schedule Web UI: - display cost schedules - load cost items - Add cost items - Edit cost item names --- src/bonsai/bonsai/bim/data/webui/sioserver.py | 17 +- .../data/webui/static/css/components/card.css | 58 +++ .../bonsai/bim/data/webui/static/js/cost.js | 168 +++++++ .../data/webui/static/js/utilities/costui.js | 464 ++++++++++++++++++ .../bim/data/webui/templates/costing.html | 120 +++++ .../bim/data/webui/templates/drawings.html | 5 + .../bim/data/webui/templates/gantt.html | 8 +- .../bim/data/webui/templates/index.html | 5 + src/bonsai/bonsai/tool/cost.py | 45 ++ src/bonsai/bonsai/tool/web.py | 46 ++ 10 files changed, 934 insertions(+), 2 deletions(-) create mode 100644 src/bonsai/bonsai/bim/data/webui/static/css/components/card.css create mode 100644 src/bonsai/bonsai/bim/data/webui/static/js/cost.js create mode 100644 src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js create mode 100644 src/bonsai/bonsai/bim/data/webui/templates/costing.html diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index c663e7d091..37dcc81741 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -115,13 +115,21 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_theme = data await sio.emit("theme_data", data, namespace="/web") - # this function will be called when the event demo_data is emitted async def on_demo_data(self, sid, data): print(f"Demo data from Blender client {sid}") blender_messages[sid]["demo_data"] = data await sio.emit("demo_data", {"blenderId": sid, "data": data}, namespace="/web") + async def on_cost_items(self, sid, data): + print(f"Cost items data from Blender client {sid}") + blender_messages[sid]["cost_items"] = data + await sio.emit("cost_items", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_schedules(self, sid, data): + print(f"Cost schedule info from Blender client {sid}") + blender_messages[sid]["cost_schedules"] = data + await sio.emit("cost_schedules", {"blenderId": sid, "data": data}, namespace="/web") async def schedules(request): with open("templates/index.html", "r") as f: @@ -129,6 +137,12 @@ async def schedules(request): html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) return web.Response(text=html_content, content_type="text/html") +async def costing(request): + with open("templates/costing.html", "r") as f: + template = f.read() + html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version}) + return web.Response(text=html_content, content_type="text/html") + async def sequencing(request): with open("templates/gantt.html", "r") as f: @@ -179,6 +193,7 @@ sio.register_namespace(BlenderNamespace("/blender")) app.router.add_get("/", schedules) app.router.add_get("/documentation", documentation) app.router.add_get("/sequencing", sequencing) +app.router.add_get("/costing", costing) app.router.add_get("/demo", demo) # Add static files diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css new file mode 100644 index 0000000000..152606e72f --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css @@ -0,0 +1,58 @@ +:root.blender .flex-row { + display: flex; + flex-direction: row; + justify-content: space-between; + } + +/* CSS for the work schedule cards */ +:root.blender #work_schedules { + display: flex; + flex-wrap: wrap; + gap: 20px; + padding: 20px; + } + +:root.blender .card { + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + width: 300px; + margin: 10px; + transition: transform 0.2s; + } + +:root.blender .card:hover { + transform: scale(1.05); + } + +:root.blender .card-body { + padding: 20px; + } + +:root.blender .card-title { + font-size: 1.25rem; + margin-bottom: 10px; + } + +:root.blender .card-text { + font-size: 1rem; + margin-bottom: 20px; + } + +:root.blender .btn-primary { + background-color: #007bff; + border: none; + color: white; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 1rem; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s; + } + +:root.blender .btn-primary:hover { + background-color: #0056b3; + } \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js new file mode 100644 index 0000000000..1ea91590ed --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -0,0 +1,168 @@ +import { CostUI } from './utilities/costui.js'; + +const connectedClients = {}; +let socket; + +$(document).ready(function () { + var defaultTheme = "blender"; + var theme = localStorage.getItem("theme") || defaultTheme; + setTheme(theme); + + connectSocket(); + CostUI.createColorPicker(); +}); + +function connectSocket() { + const url = "ws://localhost:" + SOCKET_PORT + "/web"; + socket = io(url); + + socket.on("blender_connect", handleBlenderConnect); + socket.on("blender_disconnect", handleBlenderDisconnect); + socket.on("connected_clients", handleConnectedClients); + socket.on("theme_data", handleThemeData); + socket.on("connect", handleWebConnect); + socket.on("cost_schedules", handleCostSchedulesData); + socket.on("cost_items", handleCostItemsData); +} + +function handleBlenderConnect(blenderId) { + if (!connectedClients.hasOwnProperty(blenderId)) { + connectedClients[blenderId] = { shown: false, ifc_file: "" }; + } + + $("#blender-count").text(function (i, text) { + return parseInt(text, 10) + 1; + }); +} + +function handleBlenderDisconnect(blenderId) { + if (connectedClients.hasOwnProperty(blenderId)) { + delete connectedClients[blenderId]; + removeTableElement(blenderId); + } + + $("#blender-count").text(function (i, text) { + return parseInt(text, 10) - 1; + }); +} + + + +function handleConnectedClients(data) { + $("#blender-count").text(data.length); + + data.forEach(function (id) { + connectedClients[id] = { shown: false, ifc_file: "" }; + }); +} + +function handleThemeData(themeData) { + function arrayToRgbString(arr) { + const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); + if (a !== undefined) { + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + return `rgb(${r}, ${g}, ${b})`; + } + + function generateCssVariableRule(theme) { + let cssVariables = ":root.blender {\n"; + for (const key in theme) { + const cssVariableName = `--blender-${key.replace(/_/g, "-")}`; + const cssVariableValue = arrayToRgbString(theme[key]); + cssVariables += ` ${cssVariableName}: ${cssVariableValue};\n`; + } + cssVariables += "}"; + return cssVariables; + } + + const cssRule = generateCssVariableRule(themeData.theme); + + var styleElement = $("#index-stylesheet")[0]; + if (styleElement) { + var sheet = styleElement.sheet || styleElement.styleSheet; + sheet.insertRule(cssRule, sheet.cssRules.length); + } +} + +function setTheme(theme) { + $("html").removeClass("light dark blender").addClass(theme); + $(":root").css("color-scheme", theme); + if (theme === "light") { + $("#toggle-theme").html(''); + } else if (theme === "dark") { + $("#toggle-theme").html(''); + } else if (theme === "blender") { + $("#toggle-theme").html(''); + } + localStorage.setItem("theme", theme); +} + +function addCostItem(costItemId) { + console.log("addCostItem", costItemId); + executeOperator({ type: "addCostItem", costItemId: costItemId }); +} + +function editCostItemName(costItemId, name) { + executeOperator({ type: "editCostItemName", costItemId: costItemId, name: name }); +} + +function selectAssignedElements(costItemId) { + executeOperator({ type: "selectAssignedElements", costItemId: costItemId }); +} + +function handleWebConnect() { + getCostSchedules(); +} + +function handleCostSchedulesData(data) { + const blenderId = data.blenderId; + const costSchedules = data.data["cost_schedules"]["cost_schedules"]; + const currency = data.data["cost_schedules"]["currency"]["name"]; + + console.log(data.data["cost_schedules"]); + + const costScheduleDiv = $("#cost-schedules"); + + costSchedules.forEach((costSchedule) => { + costSchedule.UpdateDate = new Date(costSchedule.UpdateDate); + const mainContainer = CostUI.text("Updated On: " + costSchedule.UpdateDate); + const callback = () => loadCostSchedule(costSchedule.id, blenderId); + + + const card = CostUI.createCard(costSchedule.Name, mainContainer, callback); + costScheduleDiv.append(card); + }); +} + +function handleCostItemsData(data) { + console.log(data); + CostUI.createCostSchedule({ + data: data.data["cost_items"], + blenderID: data.blenderId, + callbacks: { + "addCostItem": addCostItem, + "selectAssignedElements": selectAssignedElements, + 'editCostItemName': editCostItemName, + } + }); +} + +function executeOperator(operator, blenderId) { + const msg = { + sourcePage: "cost", + operator: operator, + }; + if (blenderId !== undefined) { + msg.BlenderId = blenderId; + } + socket.emit("web_operator", msg); +} + +function loadCostSchedule(costScheduleId, blenderId) { + executeOperator({ type: "loadCostSchedule", costScheduleId: costScheduleId }, blenderId); +} + +function getCostSchedules(blenderId) { + executeOperator({ type: "getCostSchedules" }, blenderId); +} diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js new file mode 100644 index 0000000000..61779592bd --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -0,0 +1,464 @@ +export class CostUI { + constructor() {} + + createButton() { + console.log("Button created"); + } + + createInput() { + console.log("Input created"); + } + + static isCostScheduleLoaded(id) { + const existingTable = document.getElementById('cost-items-' + id); + return existingTable !== null; + } + + static removeCostSchedule(id) { + document.getElementById("cost-items-" + id).remove(); + } + static createTable(id) { + CostUI.isCostScheduleLoaded(id) ? CostUI.removeCostSchedule(id) : null; + + const table = document.createElement("table"); + table.id = 'cost-items-' + id; + const tbody = document.createElement("tbody"); + tbody.setAttribute("id", "cost-items"); + + const columnHeaders = ["Name", "Quantity", "Unit", "Cost", "Total Cost", "Action"]; + const tr = document.createElement("tr"); + + for (let i = 0; i < columnHeaders.length; i++) { + const th = document.createElement("th"); + th.textContent = columnHeaders[i]; + th.style.position = "relative"; // Required for the resizer handle + tr.appendChild(th); + + // Add resizer handle + if (i < columnHeaders.length - 1) { // No resizer for the last column + const resizer = document.createElement("div"); + resizer.classList.add("resizer"); + th.appendChild(resizer); + CostUI.addResizer(resizer); + } + } + + tbody.appendChild(tr); + table.appendChild(tbody); + document.getElementById("cost-items").appendChild(table); + + // Add CSS to set column widths, resizer styles, hover effect, and color scheme + CostUI.addTableStyles(id); + + // Create context menu + CostUI.createContextMenu(); + + table.get_blender_id = function() { + return this.getAttribute("id").split("-")[2]; + }; + + return [table, tbody]; + } + + static addTableStyles(id) { + const style = document.createElement("style"); + style.textContent = ` + #cost-items-${id} th:nth-child(1), + #cost-items-${id} td:nth-child(1) { + width: auto; + } + #cost-items-${id} th:not(:nth-child(1)), + #cost-items-${id} td:not(:nth-child(1)) { + width: 100px; /* Set a fixed width for other columns */ + } + th { + position: relative; + } + .resizer { + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + user-select: none; + } + .context-menu { + display: none; + position: absolute; + background-color: white; + border: 1px solid #ccc; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + } + .context-menu button { + display: block; + width: 100%; + padding: 8px; + border: none; + background: none; + text-align: left; + cursor: pointer; + } + .context-menu button:hover { + background-color: #f0f0f0; + } + #cost-items tr:hover { + background-color: #f0f0f0; /* Change this color to your desired hover color */ + } + `; + document.head.appendChild(style); + } + + static createContextMenu() { + // Create context menu + const contextMenu = document.createElement("div"); + contextMenu.id = "context-menu"; + contextMenu.classList.add("context-menu"); + contextMenu.innerHTML = ` + + + + `; + document.body.appendChild(contextMenu); + + // Add event listeners for context menu + document.addEventListener("contextmenu", function(event) { + event.preventDefault(); + const targetRow = event.target.closest("tr"); + if (targetRow && targetRow.parentElement.id === "cost-items") { + const contextMenu = document.getElementById("context-menu"); + contextMenu.style.display = "block"; + contextMenu.style.left = `${event.pageX}px`; + contextMenu.style.top = `${event.pageY}px`; + + // Store the target row in the context menu for later use + contextMenu.targetRow = targetRow; + } else { + document.getElementById("context-menu").style.display = "none"; + } + }); + + document.addEventListener("click", function(event) { + const contextMenu = document.getElementById("context-menu"); + if (!contextMenu.contains(event.target)) { + contextMenu.style.display = "none"; + } + }); + + document.getElementById("edit-button").addEventListener("click", function() { + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + // Implement your edit action here + console.log("Edit row:", targetRow.getAttribute("id")); + } + }); + + document.getElementById("delete-button").addEventListener("click", function() { + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + // Implement your delete action here + console.log("Delete row:", targetRow.getAttribute("id")); + targetRow.remove(); + } + }); + + document.getElementById("duplicate-button").addEventListener("click", function() { + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + // Implement your duplicate action here + console.log("Duplicate row:", targetRow.getAttribute("id")); + const newRow = targetRow.cloneNode(true); + targetRow.parentElement.appendChild(newRow); + } + }); + } + + static addResizer(resizer) { + let startX, startWidth, th; + + resizer.addEventListener("mousedown", function(e) { + th = e.target.parentElement; + startX = e.pageX; + startWidth = th.offsetWidth; + document.addEventListener("mousemove", resizeColumn); + document.addEventListener("mouseup", stopResize); + }); + + function resizeColumn(e) { + const newWidth = startWidth + (e.pageX - startX); + th.style.width = newWidth + "px"; + } + + function stopResize() { + document.removeEventListener("mousemove", resizeColumn); + document.removeEventListener("mouseup", stopResize); + } + } + + static generateColorScheme(baseColor) { + // This function generates a color scheme based on the base color + // For simplicity, we'll just lighten the base color for each level + const levels = 7; // Number of levels of nesting + const colorScheme = []; + for (let i = 0; i < levels; i++) { + colorScheme.push(CostUI.lightenColor(baseColor, i * 7)); + } + return colorScheme; + } + + static lightenColor(color, percent) { + // This function lightens a color by a given percentage + const num = parseInt(color.slice(1), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) + amt, + G = (num >> 8 & 0x00FF) + amt, + B = (num & 0x0000FF) + amt; + return `#${(0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1).toUpperCase()}`; + } + + static applyColorScheme(tableId, colorScheme) { + const rows = document.querySelectorAll(`#${tableId} tbody tr`); + rows.forEach((row, index) => { + const level = index % colorScheme.length; // Assuming level is determined by index for simplicity + row.style.backgroundColor = colorScheme[level]; + }); + } + + + static createColorPicker() { + const colorPicker = document.createElement("input"); + colorPicker.type = "color"; + colorPicker.id = "color-picker"; + colorPicker.value = "#ff0000"; // Default color + const colorText = document.createElement("p"); + colorText.textContent = "Select a color to change row color:"; + document.getElementById("UI").appendChild(colorText); + document.getElementById("UI").appendChild(colorPicker); + + colorPicker.addEventListener("input", function() { + const baseColor = colorPicker.value; + const colorScheme = CostUI.generateColorScheme(baseColor); + CostUI.applyColorScheme("cost-items", colorScheme); + }); + const colorScheme = CostUI.generateColorScheme("#000000"); + CostUI.applyColorScheme("cost-items", colorScheme); + } + + static createCostSchedule({ data, blenderID, title, callbacks = {} }) { + const [table, tbody] = CostUI.createTable(blenderID); + CostUI.createCostItem(data, tbody, 0, null, callbacks); + CostUI.applyExpandedState(); + } + + static createCostItem(data, container, nestingLevel = 0, parentID = null, callbacks = {}) { + data.forEach(obj => { + const row = CostUI.createRow(obj, nestingLevel, parentID, callbacks); + container.appendChild(row); + + if (obj.is_nested_by && obj.is_nested_by.length > 0) { + CostUI.createCostItem(obj.is_nested_by, container, nestingLevel + 1, obj.id, callbacks); + } + }); + } + + static createRow(obj, nestingLevel, parentID, callbacks = {}) { + const row = document.createElement("tr"); + row.setAttribute("id", obj.id); + row.setAttribute("parent-id", parentID); + if (nestingLevel > 0) { + row.classList.add("nested"); + row.classList.add(`level-${nestingLevel}`); + } + const expandButton = document.createElement("button"); + expandButton.classList.add("toggle-button"); + if (obj.is_nested_by && obj.is_nested_by.length > 0) { + expandButton.textContent = ">"; + } else { + expandButton.style.visibility = "hidden"; + } + //row.appendChild(expandButton); + + expandButton.addEventListener("click", function() { + CostUI.contractExpandRow.call(this, obj.id); + }); + + const nameCell = document.createElement("td"); + const nameInput = document.createElement("input"); + nameInput.value = obj.name ? obj.name : "Unnamed"; + + nameInput.addEventListener("change", function() { + callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; + }); + + nameInput.addEventListener("keydown", function(event) { + if (event.key === "Enter") { + callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; + } + }); + nameCell.style.paddingLeft = nestingLevel * 20 + "px"; + nameCell.appendChild(expandButton); + nameCell.appendChild(nameInput); + row.appendChild(nameCell); + + const totalCostQuantityCell = document.createElement("td"); + totalCostQuantityCell.textContent = obj.TotalCostQuantity; + row.appendChild(totalCostQuantityCell); + + const unitSymbolCell = document.createElement("td"); + unitSymbolCell.textContent = obj.UnitSymbol; + row.appendChild(unitSymbolCell); + + const totalAppliedValueCell = document.createElement("td"); + totalAppliedValueCell.textContent = obj.TotalAppliedValue; + row.appendChild(totalAppliedValueCell); + + const totalCostCell = document.createElement("td"); + const totalCost = parseFloat(obj.TotalCost).toFixed(2); + + totalCostCell.textContent = obj.is_sum ? totalCost + " (Σ)" : totalCost; + + row.appendChild(totalCostCell); + + const divFlex = document.createElement("div"); + divFlex.classList.add("flex-container"); + const addButton = document.createElement("button"); + addButton.textContent = "+"; + addButton.classList.add("add-button"); + addButton.addEventListener("click", function(e) { + e.stopPropagation(); + callbacks.addCostItem ? callbacks.addCostItem(obj.id) : null; + }); + + const selectButton = document.createElement("button"); + selectButton.textContent = "Select"; + selectButton.addEventListener("click", function(e) { + e.stopPropagation(); + callbacks.selectAssignedElements ? callbacks.selectAssignedElements(obj.id) : null; + }); + + divFlex.appendChild(addButton); + divFlex.appendChild(selectButton); + + const flexContainerCell = document.createElement("td"); + flexContainerCell.appendChild(divFlex); + row.appendChild(flexContainerCell); + + row.get_id = function() { + return this.getAttribute("id"); + }; + + row.get_parent = function() { + const parentId = this.getAttribute("parent-id"); + return parentId ? document.getElementById(parentId) : null; + }; + + return row; + } + + static hideNestedRows(parentId) { + const rows = document.querySelectorAll(`[parent-id='${parentId}']`); + rows.forEach(row => { + row.classList.add("nested"); + const childId = row.getAttribute('id'); + if (childId) { + CostUI.hideNestedRows(childId); + } + }); + } + + static showNestedRows(parentId) { + const rows = document.querySelectorAll(`[parent-id='${parentId}']`); + rows.forEach(row => { + row.classList.remove("nested"); + const childId = row.getAttribute('id'); + if (childId && CostUI.isRowExpanded(childId)) { + CostUI.showNestedRows(childId); + } + }); + } + + static contractExpandRow(id) { + const rows = document.querySelectorAll(`[parent-id='${id}']`); + if (rows.length === 0) { + return; + } + + let isVisible = false; + rows.forEach(row => { + if (!row.classList.contains("nested")) { + isVisible = true; + } + }); + + if (isVisible) { + CostUI.hideNestedRows(id); + this.textContent = ">"; + CostUI.updateExpandedState(id, false); + } else { + CostUI.showNestedRows(id); + this.textContent = "^"; + CostUI.updateExpandedState(id, true); + } + } + + static updateExpandedState(id, isExpanded) { + const expandedState = JSON.parse(localStorage.getItem('expandedState')) || {}; + expandedState[id] = isExpanded; + localStorage.setItem('expandedState', JSON.stringify(expandedState)); + } + + static applyExpandedState() { + const expandedState = JSON.parse(localStorage.getItem('expandedState')) || {}; + Object.keys(expandedState).forEach(id => { + if (expandedState[id]) { + CostUI.showNestedRows(id); + const toggleButton = document.querySelector(`[id='${id}'] .toggle-button`); + if (toggleButton) { + toggleButton.textContent = "^"; + } + } else { + CostUI.hideNestedRows(id); + const toggleButton = document.querySelector(`[id='${id}'] .toggle-button`); + if (toggleButton) { + toggleButton.textContent = ">"; + } + } + }); + } + + static isRowExpanded(id) { + const expandedState = JSON.parse(localStorage.getItem('expandedState')) || {}; + return expandedState[id] || false; + } + + static text(label) { + const text = document.createElement("p"); + text.textContent = label; + return text; + } + + static createCard(title, mainContainer, callback) { + const card = document.createElement("div"); + card.classList.add("card"); + + const cardBody = document.createElement("div"); + cardBody.classList.add("card-body"); + + const cardTitle = document.createElement("h5"); + cardTitle.classList.add("card-title"); + cardTitle.textContent = title; + + const cardButton = document.createElement("button"); + cardButton.classList.add("btn", "btn-primary"); + cardButton.textContent = "Load"; + cardButton.addEventListener("click", callback); + + cardBody.appendChild(cardTitle); + cardBody.appendChild(mainContainer); + cardBody.appendChild(cardButton); + card.appendChild(cardBody); + + return card; + } +} diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html new file mode 100644 index 0000000000..6bd6ddeee7 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -0,0 +1,120 @@ + + + + + + BlenderBIM Web UI + + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + diff --git a/src/bonsai/bonsai/bim/data/webui/templates/drawings.html b/src/bonsai/bonsai/bim/data/webui/templates/drawings.html index 858f1edcf9..d45d9b51bf 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/drawings.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/drawings.html @@ -53,6 +53,11 @@
  • Schedules
  • +
  • + Costing +
  • Construction Sequencing var SOCKET_PORT = {{port}}; - +