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() {

)} - {project.budget && ( - <> + {(() => { + const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost; + if (totalCost <= 0) return null; + return ( +
+

{t('projectDetail.cost.totalCost')}

+

+ {currency}{totalCost.toFixed(2)} +

+ {stats.bom_cost > 0 && ( +

{t('projectDetail.cost.includesBom')}

+ )} +
+ ); + })()} + {project.budget !== null && (() => { + const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost; + const remaining = project.budget - totalCost; + return (

{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)}

- - )} + ); + })()} @@ -1132,11 +1151,11 @@ export function ProjectDetailPage() { ))} {/* BOM Total */} - {bomItems.some(item => item.unit_price !== null) && ( + {stats && stats.bom_cost > 0 && (
{t('projectDetail.bom.totalCost')} - {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)} + {currency}{stats.bom_cost.toFixed(2)}
)} @@ -1270,6 +1289,7 @@ export function ProjectDetailPage() { {showEditModal && ( void; onSave: (data: ProjectCreate | ProjectUpdate) => void; isLoading: boolean; + currencySymbol: string; t: TFunction; } -export function ProjectModal({ project, onClose, onSave, isLoading, t }: ProjectModalProps) { +export function ProjectModal({ project, onClose, onSave, isLoading, currencySymbol, t }: ProjectModalProps) { const [name, setName] = useState(project?.name || ''); const [description, setDescription] = useState(project?.description || ''); const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]); @@ -59,6 +61,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, t }: Project const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || ''); const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || ''); const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal'); + const [budget, setBudget] = useState(project?.budget?.toString() || ''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -71,6 +74,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, t }: Project tags: tags.trim() || undefined, due_date: dueDate || undefined, priority, + budget: budget.trim() ? parseFloat(budget) : null, ...(project && { status }), }); }; @@ -207,6 +211,26 @@ export function ProjectModal({ project, onClose, onSave, isLoading, t }: Project +
+ +
+ + {currencySymbol} + + setBudget(e.target.value)} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded pl-8 pr-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" + placeholder="0.00" + /> +
+
+ {project && (