Просмотр исходного кода

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

[Feature] Add Total cost to Projects (#733)
Keybored 5 месяцев назад
Родитель
Сommit
b12c51189d

+ 5 - 1
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(
             func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
                 "completed"
                 "completed"
             ),
             ),
+            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
         ).where(ProjectBOMItem.project_id == project_id)
         ).where(ProjectBOMItem.project_id == project_id)
     )
     )
     bom_stats = bom_result.first()
     bom_stats = bom_result.first()
@@ -149,6 +150,7 @@ async def compute_project_stats(
         remaining_parts=remaining_parts,
         remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
         bom_total_items=bom_stats.total or 0,
         bom_completed_items=int(bom_stats.completed 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,
                 status=project.status,
                 target_count=project.target_count,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
                 target_parts_count=project.target_parts_count,
+                budget=project.budget,
                 created_at=project.created_at,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 archive_count=archive_count,
                 total_items=total_items,
                 total_items=total_items,
@@ -346,6 +349,7 @@ async def list_templates(
                 color=project.color,
                 color=project.color,
                 status=project.status,
                 status=project.status,
                 target_count=project.target_count,
                 target_count=project.target_count,
+                budget=project.budget,
                 created_at=project.created_at,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 archive_count=archive_count,
                 queue_count=0,
                 queue_count=0,
@@ -561,7 +565,7 @@ async def update_project(
         if data.priority not in ["low", "normal", "high", "urgent"]:
         if data.priority not in ["low", "normal", "high", "urgent"]:
             raise HTTPException(status_code=400, detail="Invalid priority")
             raise HTTPException(status_code=400, detail="Invalid priority")
         project.priority = data.priority
         project.priority = data.priority
-    if data.budget is not None:
+    if "budget" in data.model_fields_set:
         project.budget = data.budget
         project.budget = data.budget
     if data.parent_id is not None:
     if data.parent_id is not None:
         # Verify parent exists and prevent circular reference
         # Verify parent exists and prevent circular reference

+ 2 - 0
backend/app/schemas/project.py

@@ -58,6 +58,7 @@ class ProjectStats(BaseModel):
     # BOM stats (Phase 7)
     # BOM stats (Phase 7)
     bom_total_items: int = 0
     bom_total_items: int = 0
     bom_completed_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):
 class ProjectChildPreview(BaseModel):
@@ -120,6 +121,7 @@ class ProjectListResponse(BaseModel):
     status: str
     status: str
     target_count: int | None
     target_count: int | None
     target_parts_count: int | None = None
     target_parts_count: int | None = None
+    budget: float | None = None
     created_at: datetime
     created_at: datetime
     # Quick stats
     # Quick stats
     archive_count: int = 0  # Number of print jobs
     archive_count: int = 0  # Number of print jobs

+ 5 - 3
frontend/src/api/client.ts

@@ -553,6 +553,7 @@ export interface ProjectStats {
   remaining_parts: number | null;  // Remaining parts
   remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;
   bom_total_items: number;
   bom_completed_items: number;
   bom_completed_items: number;
+  bom_cost: number;
 }
 }
 
 
 export interface ProjectChildPreview {
 export interface ProjectChildPreview {
@@ -611,6 +612,7 @@ export interface ProjectListItem {
   status: string;
   status: string;
   target_count: number | null;  // Target number of plates/print jobs
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
   target_parts_count: number | null;  // Target number of parts/objects
+  budget: number | null;
   created_at: string;
   created_at: string;
   archive_count: number;  // Number of print jobs (plates)
   archive_count: number;  // Number of print jobs (plates)
   total_items: number;  // Sum of quantities (total items printed, including failed)
   total_items: number;  // Sum of quantities (total items printed, including failed)
@@ -631,7 +633,7 @@ export interface ProjectCreate {
   tags?: string;
   tags?: string;
   due_date?: string;
   due_date?: string;
   priority?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   parent_id?: number;
   parent_id?: number;
 }
 }
 
 
@@ -646,7 +648,7 @@ export interface ProjectUpdate {
   tags?: string;
   tags?: string;
   due_date?: string;
   due_date?: string;
   priority?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   parent_id?: number;
   parent_id?: number;
 }
 }
 
 
@@ -732,7 +734,7 @@ export interface ProjectImport {
   tags?: string;
   tags?: string;
   due_date?: string;
   due_date?: string;
   priority?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   bom_items?: BOMItemExport[];
   bom_items?: BOMItemExport[];
   linked_folders?: LinkedFolderExport[];
   linked_folders?: LinkedFolderExport[];
 }
 }

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2694,6 +2694,9 @@ export default {
       title: 'Kostenverfolgung',
       title: 'Kostenverfolgung',
       filamentCost: 'Filamentkosten',
       filamentCost: 'Filamentkosten',
       energy: 'Energie',
       energy: 'Energie',
+      totalCost: 'Gesamtkosten',
+      total: 'Gesamt',
+      includesBom: 'inkl. Stückliste',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Verbleibend',
       remaining: 'Verbleibend',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2694,6 +2694,9 @@ export default {
       title: 'Cost Tracking',
       title: 'Cost Tracking',
       filamentCost: 'Filament Cost',
       filamentCost: 'Filament Cost',
       energy: 'Energy',
       energy: 'Energy',
+      totalCost: 'Total Cost',
+      total: 'Total',
+      includesBom: 'incl. BOM',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Remaining',
       remaining: 'Remaining',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2681,6 +2681,9 @@ export default {
       title: 'Suivi des coûts',
       title: 'Suivi des coûts',
       filamentCost: 'Coût Filament',
       filamentCost: 'Coût Filament',
       energy: 'Énergie',
       energy: 'Énergie',
+      totalCost: 'Coût Total',
+      total: 'Total',
+      includesBom: 'BOM incluse',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Restant',
       remaining: 'Restant',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2680,6 +2680,9 @@ export default {
       title: 'Tracciamento costi',
       title: 'Tracciamento costi',
       filamentCost: 'Costo filamento',
       filamentCost: 'Costo filamento',
       energy: 'Energia',
       energy: 'Energia',
+      totalCost: 'Costo totale',
+      total: 'Totale',
+      includesBom: 'incl. distinta materiali',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Rimanente',
       remaining: 'Rimanente',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2693,6 +2693,9 @@ export default {
       title: 'コスト追跡',
       title: 'コスト追跡',
       filamentCost: 'フィラメント',
       filamentCost: 'フィラメント',
       energy: 'エネルギー',
       energy: 'エネルギー',
+      totalCost: '合計コスト',
+      total: '合計',
+      includesBom: 'BOM含む',
       budget: '予算',
       budget: '予算',
       remaining: '残り',
       remaining: '残り',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2680,6 +2680,9 @@ export default {
       title: 'Rastreamento de Custos',
       title: 'Rastreamento de Custos',
       filamentCost: 'Custo do Filamento',
       filamentCost: 'Custo do Filamento',
       energy: 'Energia',
       energy: 'Energia',
+      totalCost: 'Custo Total',
+      total: 'Total',
+      includesBom: 'incl. lista de materiais',
       budget: 'Orçamento',
       budget: 'Orçamento',
       remaining: 'Restante',
       remaining: 'Restante',
     },
     },

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2680,6 +2680,9 @@ export default {
       title: '成本追踪',
       title: '成本追踪',
       filamentCost: '耗材成本',
       filamentCost: '耗材成本',
       energy: '能源',
       energy: '能源',
+      totalCost: '总成本',
+      total: '总计',
+      includesBom: '含物料清单',
       budget: '预算',
       budget: '预算',
       remaining: '剩余',
       remaining: '剩余',
     },
     },

+ 32 - 12
frontend/src/pages/ProjectDetailPage.tsx

@@ -610,7 +610,10 @@ export function ProjectDetailPage() {
       )}
       )}
 
 
       {/* Cost tracking */}
       {/* 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>
         <Card>
           <CardContent className="p-4">
           <CardContent className="p-4">
             <h2 className="text-lg font-semibold text-white mb-3">
             <h2 className="text-lg font-semibold text-white mb-3">
@@ -636,20 +639,36 @@ export function ProjectDetailPage() {
                   </p>
                   </p>
                 </div>
                 </div>
               )}
               )}
-              {project.budget && (
-                <>
+              {(() => {
+                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+                if (totalCost <= 0) return null;
+                return (
                   <div>
                   <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>
+                    <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>
                   </div>
+                );
+              })()}
+              {project.budget !== null && (() => {
+                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+                const remaining = project.budget - totalCost;
+                return (
                   <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-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
+                    <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>
                     </p>
                   </div>
                   </div>
-                </>
-              )}
+                );
+              })()}
             </div>
             </div>
           </CardContent>
           </CardContent>
         </Card>
         </Card>
@@ -1132,11 +1151,11 @@ export function ProjectDetailPage() {
                 </div>
                 </div>
               ))}
               ))}
               {/* BOM Total */}
               {/* 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">
                 <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-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
                   <span className="text-white font-medium">
                   <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>
                   </span>
                 </div>
                 </div>
               )}
               )}
@@ -1270,6 +1289,7 @@ export function ProjectDetailPage() {
       {showEditModal && (
       {showEditModal && (
         <ProjectModal
         <ProjectModal
           t={t}
           t={t}
+          currencySymbol={currency}
           project={{
           project={{
             ...project,
             ...project,
             archive_count: stats?.total_archives || 0,
             archive_count: stats?.total_archives || 0,

+ 33 - 1
frontend/src/pages/ProjectsPage.tsx

@@ -26,6 +26,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { getCurrencySymbol } from '../utils/currency';
 
 
 const PROJECT_COLORS = [
 const PROJECT_COLORS = [
   '#ef4444', // red
   '#ef4444', // red
@@ -46,10 +47,11 @@ interface ProjectModalProps {
   onClose: () => void;
   onClose: () => void;
   onSave: (data: ProjectCreate | ProjectUpdate) => void;
   onSave: (data: ProjectCreate | ProjectUpdate) => void;
   isLoading: boolean;
   isLoading: boolean;
+  currencySymbol: string;
   t: TFunction;
   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 [name, setName] = useState(project?.name || '');
   const [description, setDescription] = useState(project?.description || '');
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
   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 [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 [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 [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
+  const [budget, setBudget] = useState(project?.budget?.toString() || '');
 
 
   const handleSubmit = (e: React.FormEvent) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -71,6 +74,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading, t }: Project
       tags: tags.trim() || undefined,
       tags: tags.trim() || undefined,
       due_date: dueDate || undefined,
       due_date: dueDate || undefined,
       priority,
       priority,
+      budget: budget.trim() ? parseFloat(budget) : null,
       ...(project && { status }),
       ...(project && { status }),
     });
     });
   };
   };
@@ -207,6 +211,26 @@ export function ProjectModal({ project, onClose, onSave, isLoading, t }: Project
             </div>
             </div>
           </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 && (
           {project && (
             <div>
             <div>
               <label className="block text-sm font-medium text-white mb-1">
               <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 [statusFilter, setStatusFilter] = useState<string>('active');
   const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
   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({
   const { data: projects, isLoading } = useQuery({
     queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
     queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
     queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
     queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
@@ -899,6 +930,7 @@ export function ProjectsPage() {
           }}
           }}
           onSave={handleSave}
           onSave={handleSave}
           isLoading={createMutation.isPending || updateMutation.isPending}
           isLoading={createMutation.isPending || updateMutation.isPending}
+          currencySymbol={currencySymbol}
           t={t}
           t={t}
         />
         />
       )}
       )}