From b12c51189d4298fcb4dc78823b7ada067cea35ef Mon Sep 17 00:00:00 2001
From: Keybored <78739882+Keybored02@users.noreply.github.com>
Date: Wed, 18 Mar 2026 07:52:59 +0100
Subject: [PATCH] [Feature] Add Total cost to Projects (#733)
[Feature] Add Total cost to Projects (#733)
---
backend/app/api/routes/projects.py | 6 +++-
backend/app/schemas/project.py | 2 ++
frontend/src/api/client.ts | 8 +++--
frontend/src/i18n/locales/de.ts | 3 ++
frontend/src/i18n/locales/en.ts | 3 ++
frontend/src/i18n/locales/fr.ts | 3 ++
frontend/src/i18n/locales/it.ts | 3 ++
frontend/src/i18n/locales/ja.ts | 3 ++
frontend/src/i18n/locales/pt-BR.ts | 3 ++
frontend/src/i18n/locales/zh-CN.ts | 3 ++
frontend/src/pages/ProjectDetailPage.tsx | 46 +++++++++++++++++-------
frontend/src/pages/ProjectsPage.tsx | 34 +++++++++++++++++-
12 files changed, 99 insertions(+), 18 deletions(-)
diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
index a795d0e68..754ab4a8b 100644
--- a/backend/app/api/routes/projects.py
+++ b/backend/app/api/routes/projects.py
@@ -127,6 +127,7 @@ async def compute_project_stats(
func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
"completed"
),
+ func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
).where(ProjectBOMItem.project_id == project_id)
)
bom_stats = bom_result.first()
@@ -149,6 +150,7 @@ async def compute_project_stats(
remaining_parts=remaining_parts,
bom_total_items=bom_stats.total or 0,
bom_completed_items=int(bom_stats.completed or 0),
+ bom_cost=round(float(bom_stats.bom_cost or 0), 2),
)
@@ -244,6 +246,7 @@ async def list_projects(
status=project.status,
target_count=project.target_count,
target_parts_count=project.target_parts_count,
+ budget=project.budget,
created_at=project.created_at,
archive_count=archive_count,
total_items=total_items,
@@ -346,6 +349,7 @@ async def list_templates(
color=project.color,
status=project.status,
target_count=project.target_count,
+ budget=project.budget,
created_at=project.created_at,
archive_count=archive_count,
queue_count=0,
@@ -561,7 +565,7 @@ async def update_project(
if data.priority not in ["low", "normal", "high", "urgent"]:
raise HTTPException(status_code=400, detail="Invalid priority")
project.priority = data.priority
- if data.budget is not None:
+ if "budget" in data.model_fields_set:
project.budget = data.budget
if data.parent_id is not None:
# Verify parent exists and prevent circular reference
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
index 74732b6cb..90e570491 100644
--- a/backend/app/schemas/project.py
+++ b/backend/app/schemas/project.py
@@ -58,6 +58,7 @@ class ProjectStats(BaseModel):
# BOM stats (Phase 7)
bom_total_items: int = 0
bom_completed_items: int = 0
+ bom_cost: float = 0.0 # Total cost of BOM items (sum of unit_price * quantity_needed)
class ProjectChildPreview(BaseModel):
@@ -120,6 +121,7 @@ class ProjectListResponse(BaseModel):
status: str
target_count: int | None
target_parts_count: int | None = None
+ budget: float | None = None
created_at: datetime
# Quick stats
archive_count: int = 0 # Number of print jobs
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 3c2dde5bd..a71a2adff 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -553,6 +553,7 @@ export interface ProjectStats {
remaining_parts: number | null; // Remaining parts
bom_total_items: number;
bom_completed_items: number;
+ bom_cost: number;
}
export interface ProjectChildPreview {
@@ -611,6 +612,7 @@ export interface ProjectListItem {
status: string;
target_count: number | null; // Target number of plates/print jobs
target_parts_count: number | null; // Target number of parts/objects
+ budget: number | null;
created_at: string;
archive_count: number; // Number of print jobs (plates)
total_items: number; // Sum of quantities (total items printed, including failed)
@@ -631,7 +633,7 @@ export interface ProjectCreate {
tags?: string;
due_date?: string;
priority?: string;
- budget?: number;
+ budget?: number | null;
parent_id?: number;
}
@@ -646,7 +648,7 @@ export interface ProjectUpdate {
tags?: string;
due_date?: string;
priority?: string;
- budget?: number;
+ budget?: number | null;
parent_id?: number;
}
@@ -732,7 +734,7 @@ export interface ProjectImport {
tags?: string;
due_date?: string;
priority?: string;
- budget?: number;
+ budget?: number | null;
bom_items?: BOMItemExport[];
linked_folders?: LinkedFolderExport[];
}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index ec5d9a034..0e7c64be2 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -2694,6 +2694,9 @@ export default {
title: 'Kostenverfolgung',
filamentCost: 'Filamentkosten',
energy: 'Energie',
+ totalCost: 'Gesamtkosten',
+ total: 'Gesamt',
+ includesBom: 'inkl. Stückliste',
budget: 'Budget',
remaining: 'Verbleibend',
},
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 511d3910b..b7e4d361e 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -2694,6 +2694,9 @@ export default {
title: 'Cost Tracking',
filamentCost: 'Filament Cost',
energy: 'Energy',
+ totalCost: 'Total Cost',
+ total: 'Total',
+ includesBom: 'incl. BOM',
budget: 'Budget',
remaining: 'Remaining',
},
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index ddec17a63..971505656 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -2681,6 +2681,9 @@ export default {
title: 'Suivi des coûts',
filamentCost: 'Coût Filament',
energy: 'Énergie',
+ totalCost: 'Coût Total',
+ total: 'Total',
+ includesBom: 'BOM incluse',
budget: 'Budget',
remaining: 'Restant',
},
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index 0b563af28..a187ee8b2 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -2680,6 +2680,9 @@ export default {
title: 'Tracciamento costi',
filamentCost: 'Costo filamento',
energy: 'Energia',
+ totalCost: 'Costo totale',
+ total: 'Totale',
+ includesBom: 'incl. distinta materiali',
budget: 'Budget',
remaining: 'Rimanente',
},
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index b1ad3dbf3..cc92e355b 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -2693,6 +2693,9 @@ export default {
title: 'コスト追跡',
filamentCost: 'フィラメント',
energy: 'エネルギー',
+ totalCost: '合計コスト',
+ total: '合計',
+ includesBom: 'BOM含む',
budget: '予算',
remaining: '残り',
},
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index f6030afbe..dc9900646 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -2680,6 +2680,9 @@ export default {
title: 'Rastreamento de Custos',
filamentCost: 'Custo do Filamento',
energy: 'Energia',
+ totalCost: 'Custo Total',
+ total: 'Total',
+ includesBom: 'incl. lista de materiais',
budget: 'Orçamento',
remaining: 'Restante',
},
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 26c8dca48..802259f05 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -2680,6 +2680,9 @@ export default {
title: '成本追踪',
filamentCost: '耗材成本',
energy: '能源',
+ totalCost: '总成本',
+ total: '总计',
+ includesBom: '含物料清单',
budget: '预算',
remaining: '剩余',
},
diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx
index 6390167f2..82e49837c 100644
--- a/frontend/src/pages/ProjectDetailPage.tsx
+++ b/frontend/src/pages/ProjectDetailPage.tsx
@@ -610,7 +610,10 @@ export function ProjectDetailPage() {
)}
{/* Cost tracking */}
- {stats && (stats.estimated_cost > 0 || project.budget) && (
+ {stats && (() => {
+ const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+ return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
+ })() && (
@@ -636,20 +639,36 @@ export function ProjectDetailPage() {
{t('projectDetail.cost.totalCost')}
++ {currency}{totalCost.toFixed(2)} +
+ {stats.bom_cost > 0 && ( +{t('projectDetail.cost.includesBom')}
+ )} +{t('projectDetail.cost.budget')}
-{currency}{project.budget.toFixed(2)}
-{t('projectDetail.cost.remaining')}
-= 0 ? 'text-bambu-green' : 'text-red-400'}`}> - {currency}{(project.budget - stats.estimated_cost).toFixed(2)} +
+ {t('projectDetail.cost.total')}: {currency}{project.budget.toFixed(2)} +
+= 0 ? 'text-bambu-green' : 'text-red-400'}`}> + {t('projectDetail.cost.remaining')}: {currency}{remaining.toFixed(2)}