mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
[Feature] Add Total cost to Projects (#733)
[Feature] Add Total cost to Projects (#733)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -2694,6 +2694,9 @@ export default {
|
||||
title: 'Kostenverfolgung',
|
||||
filamentCost: 'Filamentkosten',
|
||||
energy: 'Energie',
|
||||
totalCost: 'Gesamtkosten',
|
||||
total: 'Gesamt',
|
||||
includesBom: 'inkl. Stückliste',
|
||||
budget: 'Budget',
|
||||
remaining: 'Verbleibend',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -2693,6 +2693,9 @@ export default {
|
||||
title: 'コスト追跡',
|
||||
filamentCost: 'フィラメント',
|
||||
energy: 'エネルギー',
|
||||
totalCost: '合計コスト',
|
||||
total: '合計',
|
||||
includesBom: 'BOM含む',
|
||||
budget: '予算',
|
||||
remaining: '残り',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -2680,6 +2680,9 @@ export default {
|
||||
title: '成本追踪',
|
||||
filamentCost: '耗材成本',
|
||||
energy: '能源',
|
||||
totalCost: '总成本',
|
||||
total: '总计',
|
||||
includesBom: '含物料清单',
|
||||
budget: '预算',
|
||||
remaining: '剩余',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user