diff --git a/models/capex.py b/models/capex.py index d088910..f926daf 100644 --- a/models/capex.py +++ b/models/capex.py @@ -10,8 +10,12 @@ class Capex(Base): scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) amount = Column(Float, nullable=False) description = Column(String, nullable=True) + currency_code = Column(String(3), nullable=False, default="USD") scenario = relationship("Scenario", back_populates="capex_items") def __repr__(self): - return f"" \ No newline at end of file + return ( + f"" + ) diff --git a/models/consumption.py b/models/consumption.py index d5f0b30..c5239bc 100644 --- a/models/consumption.py +++ b/models/consumption.py @@ -10,8 +10,13 @@ class Consumption(Base): scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) amount = Column(Float, nullable=False) description = Column(String, nullable=True) + unit_name = Column(String(64), nullable=True) + unit_symbol = Column(String(16), nullable=True) scenario = relationship("Scenario", back_populates="consumption_items") def __repr__(self): - return f"" + return ( + f"" + ) diff --git a/models/opex.py b/models/opex.py index 50df6c1..10c6308 100644 --- a/models/opex.py +++ b/models/opex.py @@ -10,8 +10,12 @@ class Opex(Base): scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) amount = Column(Float, nullable=False) description = Column(String, nullable=True) + currency_code = Column(String(3), nullable=False, default="USD") scenario = relationship("Scenario", back_populates="opex_items") def __repr__(self): - return f"" + return ( + f"" + ) diff --git a/models/production_output.py b/models/production_output.py index 95cdd05..a700d57 100644 --- a/models/production_output.py +++ b/models/production_output.py @@ -10,9 +10,14 @@ class ProductionOutput(Base): scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) amount = Column(Float, nullable=False) description = Column(String, nullable=True) + unit_name = Column(String(64), nullable=True) + unit_symbol = Column(String(16), nullable=True) scenario = relationship( "Scenario", back_populates="production_output_items") def __repr__(self): - return f"" + return ( + f"" + ) diff --git a/routes/consumption.py b/routes/consumption.py index 9201124..4fee0d2 100644 --- a/routes/consumption.py +++ b/routes/consumption.py @@ -1,7 +1,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, status -from pydantic import BaseModel, ConfigDict, PositiveFloat +from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator from sqlalchemy.orm import Session from models.consumption import Consumption @@ -9,10 +9,22 @@ from routes.dependencies import get_db router = APIRouter(prefix="/api/consumption", tags=["Consumption"]) + + class ConsumptionBase(BaseModel): scenario_id: int amount: PositiveFloat description: Optional[str] = None + unit_name: Optional[str] = None + unit_symbol: Optional[str] = None + + @field_validator("unit_name", "unit_symbol") + @classmethod + def _normalize_text(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + stripped = value.strip() + return stripped or None class ConsumptionCreate(ConsumptionBase): diff --git a/routes/costs.py b/routes/costs.py index 277dcd8..d13beb9 100644 --- a/routes/costs.py +++ b/routes/costs.py @@ -1,7 +1,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy.orm import Session from models.capex import Capex @@ -9,28 +9,36 @@ from models.opex import Opex from routes.dependencies import get_db router = APIRouter(prefix="/api/costs", tags=["Costs"]) -# Pydantic schemas for Capex +# Pydantic schemas for CAPEX and OPEX -class CapexCreate(BaseModel): +class _CostBase(BaseModel): scenario_id: int amount: float description: Optional[str] = None + currency_code: str = "USD" + + @field_validator("currency_code") + @classmethod + def _normalize_currency(cls, value: Optional[str]) -> str: + code = (value or "USD").strip().upper() + return code[:3] if len(code) > 3 else code -class CapexRead(CapexCreate): +class CapexCreate(_CostBase): + pass + + +class CapexRead(_CostBase): id: int model_config = ConfigDict(from_attributes=True) -# Pydantic schemas for Opex -class OpexCreate(BaseModel): - scenario_id: int - amount: float - description: Optional[str] = None +class OpexCreate(_CostBase): + pass -class OpexRead(OpexCreate): +class OpexRead(_CostBase): id: int model_config = ConfigDict(from_attributes=True) diff --git a/routes/production.py b/routes/production.py index 0d1de6a..264b541 100644 --- a/routes/production.py +++ b/routes/production.py @@ -1,7 +1,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, status -from pydantic import BaseModel, ConfigDict, PositiveFloat +from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator from sqlalchemy.orm import Session from models.production_output import ProductionOutput @@ -9,10 +9,22 @@ from routes.dependencies import get_db router = APIRouter(prefix="/api/production", tags=["Production"]) + + class ProductionOutputBase(BaseModel): scenario_id: int amount: PositiveFloat description: Optional[str] = None + unit_name: Optional[str] = None + unit_symbol: Optional[str] = None + + @field_validator("unit_name", "unit_symbol") + @classmethod + def _normalize_text(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + stripped = value.strip() + return stripped or None class ProductionOutputCreate(ProductionOutputBase): diff --git a/routes/ui.py b/routes/ui.py index 32f9e33..e02c988 100644 --- a/routes/ui.py +++ b/routes/ui.py @@ -19,6 +19,24 @@ from models.simulation_result import SimulationResult from routes.dependencies import get_db from services.reporting import generate_report + +CURRENCY_CHOICES: list[Dict[str, Any]] = [ + {"id": "USD", "name": "US Dollar (USD)"}, + {"id": "EUR", "name": "Euro (EUR)"}, + {"id": "GBP", "name": "British Pound (GBP)"}, + {"id": "CAD", "name": "Canadian Dollar (CAD)"}, + {"id": "AUD", "name": "Australian Dollar (AUD)"}, +] + +MEASUREMENT_UNITS: list[Dict[str, Any]] = [ + {"id": "tonnes", "name": "Tonnes", "symbol": "t"}, + {"id": "kilograms", "name": "Kilograms", "symbol": "kg"}, + {"id": "pounds", "name": "Pounds", "symbol": "lb"}, + {"id": "liters", "name": "Liters", "symbol": "L"}, + {"id": "cubic_meters", "name": "Cubic Meters", "symbol": "m3"}, + {"id": "kilowatt_hours", "name": "Kilowatt Hours", "symbol": "kWh"}, +] + router = APIRouter() # Set up Jinja2 templates directory @@ -96,6 +114,7 @@ def _load_costs(db: Session) -> Dict[str, Any]: "scenario_id": int(getattr(capex, "scenario_id")), "amount": float(getattr(capex, "amount", 0.0)), "description": getattr(capex, "description", "") or "", + "currency_code": getattr(capex, "currency_code", "USD") or "USD", } ) @@ -111,6 +130,7 @@ def _load_costs(db: Session) -> Dict[str, Any]: "scenario_id": int(getattr(opex, "scenario_id")), "amount": float(getattr(opex, "amount", 0.0)), "description": getattr(opex, "description", "") or "", + "currency_code": getattr(opex, "currency_code", "USD") or "USD", } ) @@ -131,12 +151,16 @@ def _load_consumption(db: Session) -> Dict[str, Any]: scenario_id = int(getattr(record, "scenario_id")) amount_value = float(getattr(record, "amount", 0.0)) description = getattr(record, "description", "") or "" + unit_name = getattr(record, "unit_name", None) + unit_symbol = getattr(record, "unit_symbol", None) grouped[scenario_id].append( { "id": record_id, "scenario_id": scenario_id, "amount": amount_value, "description": description, + "unit_name": unit_name, + "unit_symbol": unit_symbol, } ) return {"consumption_by_scenario": dict(grouped)} @@ -153,12 +177,16 @@ def _load_production(db: Session) -> Dict[str, Any]: scenario_id = int(getattr(record, "scenario_id")) amount_value = float(getattr(record, "amount", 0.0)) description = getattr(record, "description", "") or "" + unit_name = getattr(record, "unit_name", None) + unit_symbol = getattr(record, "unit_symbol", None) grouped[scenario_id].append( { "id": record_id, "scenario_id": scenario_id, "amount": amount_value, "description": description, + "unit_name": unit_name, + "unit_symbol": unit_symbol, } ) return {"production_by_scenario": dict(grouped)} @@ -543,6 +571,7 @@ async def costs_view(request: Request, db: Session = Depends(get_db)): context: Dict[str, Any] = {} context.update(_load_scenarios(db)) context.update(_load_costs(db)) + context["currency_options"] = CURRENCY_CHOICES return _render(request, "costs.html", context) @@ -552,6 +581,7 @@ async def consumption_view(request: Request, db: Session = Depends(get_db)): context: Dict[str, Any] = {} context.update(_load_scenarios(db)) context.update(_load_consumption(db)) + context["unit_options"] = MEASUREMENT_UNITS return _render(request, "consumption.html", context) @@ -561,6 +591,7 @@ async def production_view(request: Request, db: Session = Depends(get_db)): context: Dict[str, Any] = {} context.update(_load_scenarios(db)) context.update(_load_production(db)) + context["unit_options"] = MEASUREMENT_UNITS return _render(request, "production.html", context) diff --git a/static/js/consumption.js b/static/js/consumption.js index 1adfc52..2866dd9 100644 --- a/static/js/consumption.js +++ b/static/js/consumption.js @@ -1,6 +1,6 @@ document.addEventListener("DOMContentLoaded", () => { const dataElement = document.getElementById("consumption-data"); - let data = { scenarios: [], consumption: {} }; + let data = { scenarios: [], consumption: {}, unit_options: [] }; if (dataElement) { try { @@ -12,6 +12,9 @@ document.addEventListener("DOMContentLoaded", () => { parsed.consumption && typeof parsed.consumption === "object" ? parsed.consumption : {}, + unit_options: Array.isArray(parsed.unit_options) + ? parsed.unit_options + : [], }; } } catch (error) { @@ -26,6 +29,10 @@ document.addEventListener("DOMContentLoaded", () => { const emptyState = document.getElementById("consumption-empty"); const form = document.getElementById("consumption-form"); const feedbackEl = document.getElementById("consumption-feedback"); + const unitSelect = document.getElementById("consumption-form-unit"); + const unitSymbolInput = document.getElementById( + "consumption-form-unit-symbol" + ); const showFeedback = (message, type = "success") => { if (!feedbackEl) { @@ -50,6 +57,16 @@ document.addEventListener("DOMContentLoaded", () => { maximumFractionDigits: 2, }); + const formatMeasurement = (amount, symbol, name) => { + if (symbol) { + return `${formatAmount(amount)} ${symbol}`; + } + if (name) { + return `${formatAmount(amount)} ${name}`; + } + return formatAmount(amount); + }; + const renderConsumptionRows = (scenarioId) => { if (!tableBody || !tableWrapper || !emptyState) { return; @@ -73,7 +90,11 @@ document.addEventListener("DOMContentLoaded", () => { records.forEach((record) => { const row = document.createElement("tr"); row.innerHTML = ` - ${formatAmount(record.amount)} + ${formatMeasurement( + record.amount, + record.unit_symbol, + record.unit_name + )} ${record.description || "—"} `; tableBody.appendChild(row); @@ -107,10 +128,14 @@ document.addEventListener("DOMContentLoaded", () => { const formData = new FormData(form); const scenarioId = formData.get("scenario_id"); + const unitName = formData.get("unit_name"); + const unitSymbol = formData.get("unit_symbol"); const payload = { scenario_id: scenarioId ? Number(scenarioId) : null, amount: Number(formData.get("amount")), description: formData.get("description") || null, + unit_name: unitName ? String(unitName) : null, + unit_symbol: unitSymbol ? String(unitSymbol) : null, }; try { @@ -136,6 +161,7 @@ document.addEventListener("DOMContentLoaded", () => { consumptionByScenario[mapKey].push(result); form.reset(); + syncUnitSelection(); showFeedback("Consumption record saved.", "success"); if (filterSelect && filterSelect.value === String(result.scenario_id)) { @@ -150,6 +176,29 @@ document.addEventListener("DOMContentLoaded", () => { form.addEventListener("submit", submitConsumption); } + const syncUnitSelection = () => { + if (!unitSelect || !unitSymbolInput) { + return; + } + if (!unitSelect.value && unitSelect.options.length > 0) { + const firstOption = Array.from(unitSelect.options).find( + (option) => option.value + ); + if (firstOption) { + firstOption.selected = true; + } + } + const selectedOption = unitSelect.options[unitSelect.selectedIndex]; + unitSymbolInput.value = selectedOption + ? selectedOption.getAttribute("data-symbol") || "" + : ""; + }; + + if (unitSelect) { + unitSelect.addEventListener("change", syncUnitSelection); + syncUnitSelection(); + } + if (filterSelect && filterSelect.value) { renderConsumptionRows(filterSelect.value); } diff --git a/static/js/costs.js b/static/js/costs.js index 017d899..d0928cc 100644 --- a/static/js/costs.js +++ b/static/js/costs.js @@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", () => { const dataElement = document.getElementById("costs-payload"); let capexByScenario = {}; let opexByScenario = {}; + let currencyOptions = []; if (dataElement) { try { @@ -13,6 +14,9 @@ document.addEventListener("DOMContentLoaded", () => { if (parsed.opex && typeof parsed.opex === "object") { opexByScenario = parsed.opex; } + if (Array.isArray(parsed.currency_options)) { + currencyOptions = parsed.currency_options; + } } } catch (error) { console.error("Unable to parse cost data", error); @@ -34,6 +38,8 @@ document.addEventListener("DOMContentLoaded", () => { const opexFeedback = document.getElementById("opex-feedback"); const capexFormScenario = document.getElementById("capex-form-scenario"); const opexFormScenario = document.getElementById("opex-form-scenario"); + const capexCurrencySelect = document.getElementById("capex-form-currency"); + const opexCurrencySelect = document.getElementById("opex-form-currency"); const showFeedback = (element, message, type = "success") => { if (!element) { @@ -58,9 +64,44 @@ document.addEventListener("DOMContentLoaded", () => { maximumFractionDigits: 2, }); + const formatCurrencyAmount = (value, currencyCode) => { + if (!currencyCode) { + return formatAmount(value); + } + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currencyCode, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(Number(value)); + } catch (error) { + return `${currencyCode} ${formatAmount(value)}`; + } + }; + const sumAmount = (records) => records.reduce((total, record) => total + Number(record.amount || 0), 0); + const describeTotal = (records) => { + if (!records || records.length === 0) { + return "—"; + } + const total = sumAmount(records); + const currencyCodes = Array.from( + new Set( + records + .map((record) => (record.currency_code || "").trim().toUpperCase()) + .filter(Boolean) + ) + ); + + if (currencyCodes.length === 1) { + return formatCurrencyAmount(total, currencyCodes[0]); + } + return `${formatAmount(total)} (mixed)`; + }; + const renderCostTables = (scenarioId) => { if ( !capexTableBody || @@ -86,7 +127,7 @@ document.addEventListener("DOMContentLoaded", () => { capexRecords.forEach((record) => { const row = document.createElement("tr"); row.innerHTML = ` - ${formatAmount(record.amount)} + ${formatCurrencyAmount(record.amount, record.currency_code)} ${record.description || "—"} `; capexTableBody.appendChild(row); @@ -100,15 +141,15 @@ document.addEventListener("DOMContentLoaded", () => { opexRecords.forEach((record) => { const row = document.createElement("tr"); row.innerHTML = ` - ${formatAmount(record.amount)} + ${formatCurrencyAmount(record.amount, record.currency_code)} ${record.description || "—"} `; opexTableBody.appendChild(row); }); } - capexTotal.textContent = formatAmount(sumAmount(capexRecords)); - opexTotal.textContent = formatAmount(sumAmount(opexRecords)); + capexTotal.textContent = describeTotal(capexRecords); + opexTotal.textContent = describeTotal(opexRecords); }; const toggleCostView = (show) => { @@ -153,6 +194,18 @@ document.addEventListener("DOMContentLoaded", () => { } }; + const ensureCurrencySelection = (selectElement) => { + if (!selectElement || selectElement.value) { + return; + } + const firstOption = selectElement.querySelector( + "option[value]:not([value=''])" + ); + if (firstOption && firstOption.value) { + selectElement.value = firstOption.value; + } + }; + if (filterSelect) { filterSelect.addEventListener("change", (event) => { const value = event.target.value; @@ -173,10 +226,12 @@ document.addEventListener("DOMContentLoaded", () => { const formData = new FormData(event.target); const scenarioId = formData.get("scenario_id"); + const currencyCode = formData.get("currency_code"); const payload = { scenario_id: scenarioId ? Number(scenarioId) : null, amount: Number(formData.get("amount")), description: formData.get("description") || null, + currency_code: currencyCode ? String(currencyCode).toUpperCase() : null, }; if (!payload.scenario_id) { @@ -184,6 +239,11 @@ document.addEventListener("DOMContentLoaded", () => { return; } + if (!payload.currency_code) { + showFeedback(feedbackEl, "Choose a currency before submitting.", "error"); + return; + } + try { const response = await fetch(targetUrl, { method: "POST", @@ -206,6 +266,7 @@ document.addEventListener("DOMContentLoaded", () => { storageMap[mapKey].push(result); event.target.reset(); + ensureCurrencySelection(event.target.querySelector("select[name='currency_code']")); showFeedback(feedbackEl, "Entry saved successfully.", "success"); if (filterSelect && filterSelect.value === mapKey) { @@ -221,12 +282,14 @@ document.addEventListener("DOMContentLoaded", () => { }; if (capexForm) { + ensureCurrencySelection(capexCurrencySelect); capexForm.addEventListener("submit", (event) => submitCostEntry(event, "/api/costs/capex", capexByScenario, capexFeedback) ); } if (opexForm) { + ensureCurrencySelection(opexCurrencySelect); opexForm.addEventListener("submit", (event) => submitCostEntry(event, "/api/costs/opex", opexByScenario, opexFeedback) ); diff --git a/static/js/production.js b/static/js/production.js index 3160bd8..9d19a41 100644 --- a/static/js/production.js +++ b/static/js/production.js @@ -1,6 +1,6 @@ document.addEventListener("DOMContentLoaded", () => { const dataElement = document.getElementById("production-data"); - let data = { scenarios: [], production: {} }; + let data = { scenarios: [], production: {}, unit_options: [] }; if (dataElement) { try { @@ -12,6 +12,9 @@ document.addEventListener("DOMContentLoaded", () => { parsed.production && typeof parsed.production === "object" ? parsed.production : {}, + unit_options: Array.isArray(parsed.unit_options) + ? parsed.unit_options + : [], }; } } catch (error) { @@ -26,6 +29,8 @@ document.addEventListener("DOMContentLoaded", () => { const emptyState = document.getElementById("production-empty"); const form = document.getElementById("production-form"); const feedbackEl = document.getElementById("production-feedback"); + const unitSelect = document.getElementById("production-form-unit"); + const unitSymbolInput = document.getElementById("production-form-unit-symbol"); const showFeedback = (message, type = "success") => { if (!feedbackEl) { @@ -50,6 +55,16 @@ document.addEventListener("DOMContentLoaded", () => { maximumFractionDigits: 2, }); + const formatMeasurement = (amount, symbol, name) => { + if (symbol) { + return `${formatAmount(amount)} ${symbol}`; + } + if (name) { + return `${formatAmount(amount)} ${name}`; + } + return formatAmount(amount); + }; + const renderProductionRows = (scenarioId) => { if (!tableBody || !tableWrapper || !emptyState) { return; @@ -74,7 +89,11 @@ document.addEventListener("DOMContentLoaded", () => { records.forEach((record) => { const row = document.createElement("tr"); row.innerHTML = ` - ${formatAmount(record.amount)} + ${formatMeasurement( + record.amount, + record.unit_symbol, + record.unit_name + )} ${record.description || "—"} `; tableBody.appendChild(row); @@ -108,10 +127,14 @@ document.addEventListener("DOMContentLoaded", () => { const formData = new FormData(form); const scenarioId = formData.get("scenario_id"); + const unitName = formData.get("unit_name"); + const unitSymbol = formData.get("unit_symbol"); const payload = { scenario_id: scenarioId ? Number(scenarioId) : null, amount: Number(formData.get("amount")), description: formData.get("description") || null, + unit_name: unitName ? String(unitName) : null, + unit_symbol: unitSymbol ? String(unitSymbol) : null, }; try { @@ -137,6 +160,7 @@ document.addEventListener("DOMContentLoaded", () => { productionByScenario[mapKey].push(result); form.reset(); + syncUnitSelection(); showFeedback("Production output saved.", "success"); if (filterSelect && filterSelect.value === String(result.scenario_id)) { @@ -151,6 +175,29 @@ document.addEventListener("DOMContentLoaded", () => { form.addEventListener("submit", submitProduction); } + const syncUnitSelection = () => { + if (!unitSelect || !unitSymbolInput) { + return; + } + if (!unitSelect.value && unitSelect.options.length > 0) { + const firstOption = Array.from(unitSelect.options).find( + (option) => option.value + ); + if (firstOption) { + firstOption.selected = true; + } + } + const selectedOption = unitSelect.options[unitSelect.selectedIndex]; + unitSymbolInput.value = selectedOption + ? selectedOption.getAttribute("data-symbol") || "" + : ""; + }; + + if (unitSelect) { + unitSelect.addEventListener("change", syncUnitSelection); + syncUnitSelection(); + } + if (filterSelect && filterSelect.value) { renderProductionRows(filterSelect.value); } diff --git a/templates/consumption.html b/templates/consumption.html index 18e5c9d..625a739 100644 --- a/templates/consumption.html +++ b/templates/consumption.html @@ -28,6 +28,26 @@ title %}Consumption · CalMiner{% endblock %} {% block content %} {{ select_field( "Scenario", "consumption-form-scenario", name="scenario_id", options=scenarios, required=True, placeholder="Select a scenario", placeholder_disabled=True ) }} + +