[Feature] Add Total cost to Projects (#733)

[Feature] Add Total cost to Projects (#733)
This commit is contained in:
Keybored
2026-03-18 07:52:59 +01:00
committed by GitHub
parent 1e7173fae6
commit b12c51189d
12 changed files with 99 additions and 18 deletions
+5 -1
View File
@@ -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
+2
View File
@@ -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
+5 -3
View File
@@ -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[];
}
+3
View File
@@ -2694,6 +2694,9 @@ export default {
title: 'Kostenverfolgung',
filamentCost: 'Filamentkosten',
energy: 'Energie',
totalCost: 'Gesamtkosten',
total: 'Gesamt',
includesBom: 'inkl. Stückliste',
budget: 'Budget',
remaining: 'Verbleibend',
},
+3
View File
@@ -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',
},
+3
View File
@@ -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',
},
+3
View File
@@ -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',
},
+3
View File
@@ -2693,6 +2693,9 @@ export default {
title: 'コスト追跡',
filamentCost: 'フィラメント',
energy: 'エネルギー',
totalCost: '合計コスト',
total: '合計',
includesBom: 'BOM含む',
budget: '予算',
remaining: '残り',
},
+3
View File
@@ -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',
},
+3
View File
@@ -2680,6 +2680,9 @@ export default {
title: '成本追踪',
filamentCost: '耗材成本',
energy: '能源',
totalCost: '总成本',
total: '总计',
includesBom: '含物料清单',
budget: '预算',
remaining: '剩余',
},
+33 -13
View File
@@ -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);
})() && (
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white mb-3">
@@ -636,20 +639,36 @@ export function ProjectDetailPage() {
</p>
</div>
)}
{project.budget && (
<>
{(() => {
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
if (totalCost <= 0) return null;
return (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
<p className="text-lg font-semibold text-bambu-green">
{currency}{totalCost.toFixed(2)}
</p>
{stats.bom_cost > 0 && (
<p className="text-xs text-bambu-gray/70">{t('projectDetail.cost.includesBom')}</p>
)}
</div>
);
})()}
{project.budget !== null && (() => {
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
const remaining = project.budget - totalCost;
return (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
<p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.remaining')}</p>
<p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
{currency}{(project.budget - stats.estimated_cost).toFixed(2)}
<p className="text-sm text-bambu-gray">
{t('projectDetail.cost.total')}: <span className="text-white font-semibold">{currency}{project.budget.toFixed(2)}</span>
</p>
<p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
{t('projectDetail.cost.remaining')}: <span className="font-semibold">{currency}{remaining.toFixed(2)}</span>
</p>
</div>
</>
)}
);
})()}
</div>
</CardContent>
</Card>
@@ -1132,11 +1151,11 @@ export function ProjectDetailPage() {
</div>
))}
{/* BOM Total */}
{bomItems.some(item => item.unit_price !== null) && (
{stats && stats.bom_cost > 0 && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
<span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
<span className="text-white font-medium">
{currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
{currency}{stats.bom_cost.toFixed(2)}
</span>
</div>
)}
@@ -1270,6 +1289,7 @@ export function ProjectDetailPage() {
{showEditModal && (
<ProjectModal
t={t}
currencySymbol={currency}
project={{
...project,
archive_count: stats?.total_archives || 0,
+33 -1
View File
@@ -26,6 +26,7 @@ import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { getCurrencySymbol } from '../utils/currency';
const PROJECT_COLORS = [
'#ef4444', // red
@@ -46,10 +47,11 @@ interface ProjectModalProps {
onClose: () => 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
</div>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
{t('projectDetail.cost.budget')}
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray pointer-events-none">
{currencySymbol}
</span>
<input
type="number"
step="0.01"
min="0"
value={budget}
onChange={(e) => 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"
/>
</div>
</div>
{project && (
<div>
<label className="block text-sm font-medium text-white mb-1">
@@ -587,6 +611,13 @@ export function ProjectsPage() {
const [statusFilter, setStatusFilter] = useState<string>('active');
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
const { data: projects, isLoading } = useQuery({
queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
@@ -899,6 +930,7 @@ export function ProjectsPage() {
}}
onSave={handleSave}
isLoading={createMutation.isPending || updateMutation.isPending}
currencySymbol={currencySymbol}
t={t}
/>
)}