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 index 111dbda1e4..4d74fbc65f 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/card.css @@ -1,58 +1,27 @@ -:root.blender .flex-row { - display: flex; - flex-direction: row; - justify-content: space-between; - } +.card-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} -/* 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-title { - font-size: 1.25rem; - margin-bottom: 10px; - border-bottom: 1px solid #ddd; - padding: 10px; - } - -:root.blender .card-text { - font-size: 1rem; - margin-bottom: 20px; - padding: 10px; - } - -: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 +.card { + background-color: var(--blender-even-row, var(--nav-bg-color)); + display: flex; + flex-direction: column; + overflow-y: auto; + flex: 1; +} + +.card-header { + background-color: var(--blender-panel-header, var(--bg-color)); + border: 1px solid var(--blender-tab-outline, var(--border-color)); + flex-shrink: 0; +} + +.card-body { + background-color: var(--blender-panel-background, var(--bg-color)); + border: 1px solid var(--blender-tab-outline, var(--border-color)); + overflow-y: auto; + flex: 1; +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css index cb25216438..5378135184 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css @@ -392,7 +392,7 @@ form table { opacity: 1; } -.clickable-cell:hover { +.selected-cell, .clickable-cell:hover { border: 2px solid var(--highlight-over); cursor: pointer; } @@ -560,3 +560,10 @@ i { background-color: #ccffcc; color: darkgreen; } + +.card { + margin: 10px; +} +.card-body { + height: 10vh; +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css b/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css index 17485412d3..aef677f683 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/drawings.css @@ -1,3 +1,5 @@ +@import url("./components/card.css"); + :root { --font-family: Arial, sans-serif; --base-font-size: 16px; @@ -250,33 +252,7 @@ button:hover { overflow: hidden; } -.card-container { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} -.card { - background-color: var(--blender-even-row, var(--nav-bg-color)); - display: flex; - flex-direction: column; - overflow-y: auto; - flex: 1; -} - -.card-header { - background-color: var(--blender-panel-header, var(--bg-color)); - border: 1px solid var(--blender-tab-outline, var(--border-color)); - flex-shrink: 0; -} - -.card-body { - background-color: var(--blender-panel-background, var(--bg-color)); - border: 1px solid var(--blender-tab-outline, var(--border-color)); - overflow-y: auto; - flex: 1; -} li.svg-name { list-style-type: none; diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js index a4d8ff8849..ca683e157f 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -3,8 +3,6 @@ import { CostUI } from "./utilities/costui.js"; const connectedClients = {}; let socket; -let loadedSchedules = {}; - $(document).ready(function () { var defaultTheme = "blender"; var theme = localStorage.getItem("theme") || defaultTheme; @@ -58,6 +56,19 @@ function handleEditQuantities(data) { }); } +function assignCostValues(costRateId, callback){ + const selectedCostItems = CostUI.getSelectedCostItems(); + if (selectedCostItems.length === 0) { + return; + } + executeOperator({ + type: "assignCostValues", + costRateId: costRateId, + costItemIds: selectedCostItems, + }); +} + + function addSumCostValue(costItemId) { executeOperator({ type: "addSumCostValue", costItemId: costItemId }); } @@ -276,9 +287,6 @@ function toggleClientList() { function handleCostSchedulesData(data) { const blenderId = data.blenderId; - const loadedSchedule = loadedSchedules[blenderId] - ? loadedSchedules[blenderId] - : null; const costSchedules = data.data["cost_schedules"]["cost_schedules"]; if (costSchedules.length === 0) { return; @@ -309,17 +317,10 @@ function handleCostSchedulesData(data) { ); costScheduleDiv.append(card); }); - loadedSchedule ? CostUI.highlightElement("schedule-" + loadedSchedule) : null; } function handleCostItemsData(data) { const costScheduleId = data.data["cost_items"]["schedule_data"][0]["id"]; - const cards = document.querySelectorAll("[id^='schedule-']"); - cards.forEach((card) => { - card.classList.remove("highlighted"); - }); - CostUI.highlightElement("schedule-" + costScheduleId); - loadedSchedules[data.blenderId] = costScheduleId; const currency = data.data["cost_items"]["currency"] ? data.data["cost_items"]["currency"]["name"] : "Undefined"; @@ -338,6 +339,7 @@ function handleCostItemsData(data) { enableEditingQuantities: enableEditingQuantities, addSumCostValue: addSumCostValue, enableEditingClassification: enableEditingClassification, + assignCostValues: assignCostValues, }, }); } 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 index bdcfc530c7..29bb270351 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -70,66 +70,78 @@ export class CostUI { handleButtonClick(editCostValuesButton, callbacks.enableEditingCostValues); handleButtonClick(addButton, callbacks.addCostItem); handleButtonClick(deleteCostItemButton, callbacks.deleteCostItem); - - addTableListeners(callbacks); - - function addTableListeners(callbacks) { - function getColumnNames(tableId) { - const table = document.getElementById(tableId); - const headerRow = table.querySelector("thead tr"); - return Array.from(headerRow.children).map((headerCell) => - headerCell.textContent.trim() - ); - } - document - .getElementById("cost-items") - .addEventListener("click", function (event) { - const targetRow = event.target.closest("tr"); - const targetCell = event.target.closest("td"); - if (targetRow && targetCell) { - const columnIndex = Array.from(targetRow.children).indexOf( - targetCell - ); - const tableId = targetRow.parentElement.parentElement.id; - const costItemId = parseInt(targetRow.getAttribute("id")); - const columnName = getColumnNames(tableId)[columnIndex]; - if ( - (columnName.includes("Cost") || columnName.includes("Rate")) && - !columnName.includes("Total") - ) { - callbacks.enableEditingCostValues - ? callbacks.enableEditingCostValues(costItemId) - : null; - } - if (columnName === "Quantity") { - callbacks.enableEditingQuantities - ? callbacks.enableEditingQuantities(costItemId) - : null; - } - if (columnName === "Classification") { - callbacks.enableEditingClassification - ? callbacks.enableEditingClassification(costItemId) - : null; - } - } - }); - } } - static getColumnPreferences() { - const preferences = localStorage.getItem("columnPreferences"); - return preferences - ? JSON.parse(preferences) - : { - ID: true, - Name: true, - Quantity: true, - Unit: true, - Cost: true, - TotalCost: true, - Classification: true, - Actions: true, - }; + static addTableCallbacks(callbacks, table) { + if (table.listenersAdded) { + return; + } + + function getColumnNames(tableId) { + const table = document.getElementById(tableId); + const headerRow = table.querySelector("thead tr"); + return Array.from(headerRow.children).map((headerCell) => + headerCell.textContent.trim() + ); + } + + function addTableListeners(callbacks) { + table.addEventListener("click", function (event) { + const targetRow = event.target.closest("tr"); + const targetCell = event.target.closest("td"); + if (targetRow && targetCell) { + const columnIndex = Array.from(targetRow.children).indexOf( + targetCell + ); + const tableId = table.id; + const costItemId = parseInt(targetRow.getAttribute("id")); + const columnName = getColumnNames(tableId)[columnIndex]; + + if ( + (columnName.includes("Cost") || columnName.includes("Rate")) && + !columnName.includes("Total") && + !columnName.includes("Linked") + ) { + callbacks.enableEditingCostValues + ? callbacks.enableEditingCostValues(costItemId) + : null; + } + + if (columnName === "Quantity") { + callbacks.enableEditingQuantities + ? callbacks.enableEditingQuantities(costItemId) + : null; + } + + if (columnName === "Classification") { + callbacks.enableEditingClassification + ? callbacks.enableEditingClassification(costItemId) + : null; + } + + if (columnName === "Linked Rate") { + if (!targetRow.isRate) { + if (targetCell.classList.contains("selected-cell")) { + document.selectedCostItems = document.selectedCostItems.filter( + (costItem) => costItem !== targetRow.id + ); + targetCell.classList.remove("selected-cell"); + } else { + document.selectedCostItems.push(targetRow.id); + targetCell.classList.add("selected-cell"); + } + } + } + } + }); + } + + if (!document.selectedCostItems) { + document.selectedCostItems = []; + } + document.selectedCostItems = []; + addTableListeners(callbacks); + table.listenersAdded = true; } static addSettingsMenu() { @@ -154,7 +166,14 @@ export class CostUI { static createColumnSelector() { const columnSelector = document.createElement("div"); columnSelector.id = "column-selector"; - const columns = ["ID", "Quantity", "Unit", "Classification", "Actions"]; + const columns = [ + "Identification", + "Quantity", + "Unit", + "Classification", + "Actions", + "Linked Rate", + ]; columns.forEach((column) => { const label = document.createElement("label"); const input = document.createElement("input"); @@ -181,6 +200,23 @@ export class CostUI { localStorage.setItem("columnPreferences", JSON.stringify(preferences)); } + static getColumnPreferences() { + const preferences = localStorage.getItem("columnPreferences"); + return preferences + ? JSON.parse(preferences) + : { + Identification: true, + Name: true, + Quantity: true, + Unit: true, + Cost: true, + TotalCost: true, + Classification: true, + CostRate: true, + Actions: true, + }; + } + static updateColumnVisibility(preferences) { const tables = document.querySelectorAll("table[id^='cost-items-']"); tables.forEach((table) => { @@ -352,6 +388,7 @@ export class CostUI { currency, callbacks, }); + this.addTableCallbacks(callbacks, table); if (costSchedule["cost_items"].length === 0) { const tr = document.createElement("tr"); const td = document.createElement("td"); @@ -439,11 +476,8 @@ export class CostUI { const cells = row.querySelectorAll("td, th"); let rowText = []; - console.log(); - if (CostUI.getCostItemRow(row.id)) { const costItem = CostUI.getCostItemRow(row.id); - console.log(costItem.nestingLevel); rowText.push(costItem.nestingLevel); } else { rowText.push("Hierarchy Level"); @@ -510,7 +544,6 @@ export class CostUI { const callback = () => { tableWrapper.remove(); - CostUI.unhighlightElement("schedule-" + id); }; let closeButton = CostUI.createCloseButton(callback); tableHeader.appendChild(text); @@ -520,26 +553,36 @@ export class CostUI { const table = document.createElement("table"); table.id = "cost-items-" + id; const tbody = document.createElement("tbody"); - const columnHeaders = [ - { name: "ID", visible: preferences.ID }, + let columnHeaders = [ + { name: "Identification", visible: preferences.Identification }, { name: "Name", visible: preferences.Name }, { name: "Quantity", visible: preferences.Quantity }, { name: "Unit", visible: preferences.Unit }, { name: "Cost (" + currency + ")", visible: preferences.Cost }, { name: "Total Cost (" + currency + ")", visible: preferences.TotalCost }, { name: "Classification", visible: preferences.Classification }, + { name: "Linked Rate", visible: preferences.CostRate }, { name: "Actions", visible: preferences.Actions }, ]; + if (isScheduleOfRates) { - columnHeaders.splice(2, 1); - columnHeaders[3] = { - name: "Rate (" + currency + ")", - visible: preferences.Cost, - }; - columnHeaders[4] = { - name: "Total Rate (" + currency + ")", - visible: preferences.TotalCost, - }; + columnHeaders = columnHeaders.filter( + (header) => header.name !== "Quantity" + ); + columnHeaders = columnHeaders.filter( + (header) => header.name !== "Linked Rate" + ); + columnHeaders = columnHeaders.map((header) => { + if (header.name === "Unit") { + return { name: "Rate (" + currency + ")", visible: preferences.Cost }; + } else if (header.name === "Cost (" + currency + ")") { + return { + name: "Total Rate (" + currency + ")", + visible: preferences.TotalCost, + }; + } + return header; + }); } const thead = document.createElement("thead"); const tr = document.createElement("tr"); @@ -597,9 +640,9 @@ export class CostUI { const identification = costItem.Identification ? costItem.Identification : "XXX"; - if (preferences.ID) { + if (preferences.Identification) { const idCell = CostUI.createTableCell(identification); - idCell.setAttribute("data-column", "ID"); + idCell.setAttribute("data-column", "Identification"); row.appendChild(idCell); } @@ -633,7 +676,11 @@ export class CostUI { totalCostQuantityCell.classList.add("clickable-cell"); } if (preferences.Unit) { - const unitSymbolCell = CostUI.createTableCell(costItem.UnitSymbol); + let symbol = costItem.UnitSymbol; + if (symbol === "-" && costItem.UnitBasisUnitSymbol) { + symbol = costItem.UnitBasisUnitSymbol; + } + const unitSymbolCell = CostUI.createTableCell(symbol); unitSymbolCell.setAttribute("data-column", "Unit"); row.appendChild(unitSymbolCell); } @@ -670,8 +717,28 @@ export class CostUI { ClassificationCell.textContent = string_List; } + let costRateName = " "; + let costItemRateId; + if (costItem.CostRate.id !== null) { + costItemRateId = costItem.CostRate.id; + const costItemRateName = costItem.CostRate.Name; + const costItemRateIdentification = costItem.CostRate.Identification; + costRateName = costItemRateIdentification + " - " + costItemRateName; + } + + if (preferences.CostRate && !isScheduleOfRates) { + const costRateCell = CostUI.createTableCell(costRateName); + costRateCell.classList.add("rate-cell", "clickable-cell"); + costRateCell.setAttribute("data-column", "Linked Rate"); + row.appendChild(costRateCell); + } + if (preferences.Actions) { - const actionsCell = CostUI.costItemActions(costItem, callbacks); + const actionsCell = CostUI.costItemActions( + costItem, + isScheduleOfRates, + callbacks + ); actionsCell.setAttribute("data-column", "Actions"); actionsCell.classList.add("actions-column"); row.appendChild(actionsCell); @@ -946,7 +1013,12 @@ export class CostUI { return div; } - static costItemActions(costItem, callbacks) { + static getSelectedCostItems() { + const selectedCostItems = document.selectedCostItems || []; + return selectedCostItems.map((item) => parseInt(item, 10)); + } + + static costItemActions(costItem, isScheduleOfRates, callbacks) { const divFlex = document.createElement("div"); divFlex.classList.add("row-container"); const addCostItem = CostUI.addCostItemButton( @@ -965,12 +1037,22 @@ export class CostUI { costItem.id, callbacks.duplicateCostItem ); - - [addCostItem, duplicateButton, selectButton, deleteButton].forEach( - (button) => { - divFlex.appendChild(button); - } + const assignCostItemButton = CostUI.createButton( + "Assign", + "fa-solid fa-link" ); + assignCostItemButton.addEventListener("click", function () { + callbacks.assignCostValues(costItem.id); + }); + + let buttons = [addCostItem, duplicateButton, selectButton, deleteButton]; + if (isScheduleOfRates) { + buttons.push(assignCostItemButton); + } + + buttons.forEach((button) => { + divFlex.appendChild(button); + }); const actionsCell = document.createElement("td"); actionsCell.appendChild(divFlex); @@ -1107,12 +1189,13 @@ export class CostUI { return success; } - static createCard(id, title, mainContainer, callback) { + static createCard(id, title, cardBody, callback) { const card = document.createElement("div"); card.classList.add("card"); card.id = "schedule-" + id; - const cardBody = document.createElement("div"); + const cardContainer = document.createElement("div"); + cardContainer.classList.add("card-body"); cardBody.classList.add("card-body"); const cardTitle = CostUI.Text( @@ -1120,15 +1203,16 @@ export class CostUI { "fa-solid fa-money-bill-wave", "large" ); - cardTitle.classList.add("card-title"); + cardTitle.classList.add("card-header"); const cardButton = CostUI.createButton("Load", "fa-solid fa-arrows-rotate"); cardButton.addEventListener("click", callback); + cardTitle.appendChild(cardButton); - cardBody.appendChild(cardTitle); - cardBody.appendChild(mainContainer); - cardBody.appendChild(cardButton); - card.appendChild(cardBody); + card.appendChild(cardTitle); + card.appendChild(cardContainer); + + cardContainer.appendChild(cardBody); return card; } @@ -2714,7 +2798,6 @@ export class CostUI { const classificationContainer = document.createElement("div"); classificationContainer.classList.add("classification-container"); classificationList.appendChild(classificationContainer); - console.log(costClassifications); if (costClassifications.length === 0) { const text = "No classifications available"; const noDataMessage = this.Text( @@ -2738,12 +2821,6 @@ export class CostUI { "click", function (event) { event.preventDefault(); - console.log( - "Remove classification reference from", - costItemId, - "Classificaition Id", - classification.id - ); callbacks.removeClassificationReference( costItemId, classification.id @@ -2819,7 +2896,6 @@ export class CostUI { formContainer.appendChild(container); if (!classificationElements || classificationElements.length === 0) { - console.log("No data available"); const text = "No data available - please activate a classification file with BonsaiBIM "; const noDataMessage = this.Text( diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index c3c0bf59d2..cfc393b9ea 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -667,11 +667,7 @@ class Cost(bonsai.core.tool.Cost): @classmethod def get_cost_schedule(cls, cost_item: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: - for rel in cost_item.HasAssignments or []: - if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcCostSchedule"): - return rel.RelatingControl - for rel in cost_item.Nests or []: - return cls.get_cost_schedule(rel.RelatingObject) + return ifcopenshell.util.cost.get_cost_schedule(cost_item) @classmethod def is_cost_schedule_active(cls, cost_schedule: ifcopenshell.entity_instance) -> bool: diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 221645ab9d..c3c9270f3d 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -594,6 +594,13 @@ class Web(bonsai.core.tool.Web): data_key="message", event="message", ) + if operator_data["type"] == "assignCostValues": + cost_rate = ifc_file.by_id(operator_data["costRateId"]) + cost_schedule = tool.Cost.get_cost_schedule(ifc_file.by_id(operator_data["costItemIds"][0])) + for cost_item_id in operator_data["costItemIds"]: + cost_item = ifc_file.by_id(cost_item_id) + tool.Ifc.run("cost.assign_cost_value", cost_item=cost_item, cost_rate=cost_rate) + cls.load_cost_schedule_web_ui(cost_schedule) @classmethod def load_cost_item_quantities_ui(cls, cost_item: ifcopenshell.entity_instance) -> None: diff --git a/src/ifc5d/ifc5d/ifc2json.py b/src/ifc5d/ifc5d/ifc2json.py index f0bf63159d..fae04126af 100644 --- a/src/ifc5d/ifc5d/ifc2json.py +++ b/src/ifc5d/ifc5d/ifc2json.py @@ -55,11 +55,17 @@ class ifc5D2json: self.extract_cost_item_quantities(cost_item, data) self.populate_cost_values(cost_item, data) self.populate_nesting_index(cost_item, data) + cost_rate = ifcopenshell.util.cost.get_cost_rate(self.file, cost_item) data = {**data, **cost_item.get_info(recursive=True)} data["id"] = cost_item.id() data["IsNestedBy"] = [] data["IsSum"] = self.check_if_cost_item_is_sum(cost_item) data["Classification"] = self.get_cost_classifications(cost_item) + data["CostRate"] = { + "id": cost_rate.id() if cost_rate else None, + "Identification": cost_rate.Identification if cost_rate else None, + "Name": cost_rate.Name if cost_rate else None, + } json_data.append(data) for rel in cost_item.IsNestedBy or []: for sub_cost in rel.RelatedObjects: diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index d1990c5c48..cf320031d2 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -307,6 +307,34 @@ def get_product_quantity_names(elements: list[ifcopenshell.entity_instance]) -> return [n for n in names if n != "id"] +def get_cost_schedule(cost_item: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Returns the cost schedule of a cost item. + """ + for rel in cost_item.HasAssignments or []: + if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcCostSchedule"): + return rel.RelatingControl + for rel in cost_item.Nests or []: + return get_cost_schedule(rel.RelatingObject) + + +def get_cost_rate( + file: ifcopenshell_wrapper.file, cost_item: ifcopenshell.entity_instance +) -> Optional[ifcopenshell.entity_instance]: + """Returns the cost rate of a cost item. + + """ + # There is no direct relationship between a cost item and a cost rate in IFC, so we need to infer it, based on the assumption that cost_rate.CostValues == cost_item.CostValues. + if get_cost_schedule(cost_item).PredefinedType == "SCHEDULEOFRATES": + return None # Cost item is already a cost rate + if cost_item.CostValues: + potential_rates = file.get_inverse(cost_item.CostValues[0]) + for potential_rate in potential_rates: + schedule = get_cost_schedule(potential_rate) + if schedule.PredefinedType == "SCHEDULEOFRATES": + return potential_rate + return None + + class CostValueUnserialiser: def parse(self, formula: str): l = lark.Lark(