- Fix total print hours calculation in set_total_hours to include all

prints (not just completed), matching get_printer_total_hours behavior
  - Add option to keep or delete archives when deleting a printer
  - Custom maintenance types no longer auto-assign to all printers
  - Add UI to manually assign/remove custom maintenance types per printer
  - Add backend endpoints for assigning types to printers and removing items
  - Exclude static/assets from large file pre-commit check
This commit is contained in:
maziggy
2025-12-23 09:00:36 +01:00
parent 288a495041
commit 929c4c8cd6
19 changed files with 11483 additions and 8893 deletions
+2
View File
@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { MaintenancePage } from './pages/MaintenancePage';
import { ProjectsPage } from './pages/ProjectsPage';
import { ProjectDetailPage } from './pages/ProjectDetailPage';
import { CameraPage } from './pages/CameraPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
@@ -49,6 +50,7 @@ function App() {
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="system" element={<SystemInfoPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
+162 -2
View File
@@ -106,12 +106,16 @@ export interface PrinterStatus {
temperatures: {
bed?: number;
bed_target?: number;
bed_heating?: boolean; // Actual heater state from MQTT
nozzle?: number;
nozzle_target?: number;
nozzle_heating?: boolean; // Actual heater state from MQTT
nozzle_2?: number; // Second nozzle for H2 series (dual nozzle)
nozzle_2_target?: number;
nozzle_2_heating?: boolean; // Actual heater state from MQTT
chamber?: number;
chamber_target?: number;
chamber_heating?: boolean; // Actual heater state from MQTT
} | null;
cover_url: string | null;
hms_errors: HMSError[];
@@ -329,6 +333,20 @@ export interface ProjectStats {
total_print_time_hours: number;
total_filament_grams: number;
progress_percent: number | null;
estimated_cost: number;
total_energy_kwh: number;
total_energy_cost: number;
remaining_prints: number | null;
bom_total_items: number;
bom_completed_items: number;
}
export interface ProjectChildPreview {
id: number;
name: string;
color: string | null;
status: string;
progress_percent: number | null;
}
export interface Project {
@@ -338,11 +356,29 @@ export interface Project {
color: string | null;
status: string; // active, completed, archived
target_count: number | null;
notes: string | null;
attachments: ProjectAttachment[] | null;
tags: string | null;
due_date: string | null;
priority: string; // low, normal, high, urgent
budget: number | null;
is_template: boolean;
template_source_id: number | null;
parent_id: number | null;
parent_name: string | null;
children: ProjectChildPreview[];
created_at: string;
updated_at: string;
stats?: ProjectStats;
}
export interface ProjectAttachment {
filename: string;
original_name: string;
size: number;
uploaded_at: string;
}
export interface ArchivePreview {
id: number;
print_name: string | null;
@@ -369,6 +405,12 @@ export interface ProjectCreate {
description?: string;
color?: string;
target_count?: number;
notes?: string;
tags?: string;
due_date?: string;
priority?: string;
budget?: number;
parent_id?: number;
}
export interface ProjectUpdate {
@@ -377,6 +419,55 @@ export interface ProjectUpdate {
color?: string;
status?: string;
target_count?: number;
notes?: string;
tags?: string;
due_date?: string;
priority?: string;
budget?: number;
parent_id?: number;
}
// BOM Types
export interface BOMItem {
id: number;
project_id: number;
name: string;
quantity_needed: number;
quantity_printed: number;
archive_id: number | null;
archive_name: string | null;
stl_filename: string | null;
notes: string | null;
sort_order: number;
is_complete: boolean;
created_at: string;
updated_at: string;
}
export interface BOMItemCreate {
name: string;
quantity_needed?: number;
archive_id?: number;
stl_filename?: string;
notes?: string;
}
export interface BOMItemUpdate {
name?: string;
quantity_needed?: number;
quantity_printed?: number;
archive_id?: number;
stl_filename?: string;
notes?: string;
}
// Timeline Types
export interface TimelineEvent {
event_type: string;
timestamp: string;
title: string;
description: string | null;
metadata: Record<string, unknown> | null;
}
// API Key types
@@ -1122,8 +1213,11 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(data),
}),
deletePrinter: (id: number) =>
request<void>(`/printers/${id}`, { method: 'DELETE' }),
deletePrinter: (id: number, deleteArchives: boolean = true) =>
request<{ status: string; archives_deleted: boolean }>(
`/printers/${id}?delete_archives=${deleteArchives}`,
{ method: 'DELETE' }
),
getPrinterStatus: (id: number) =>
request<PrinterStatus>(`/printers/${id}/status`),
connectPrinter: (id: number) =>
@@ -1820,6 +1914,14 @@ export const api = {
`/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
{ method: 'PATCH' }
),
assignMaintenanceType: (printerId: number, typeId: number) =>
request<MaintenanceStatus>(`/maintenance/printers/${printerId}/assign/${typeId}`, {
method: 'POST',
}),
removeMaintenanceItem: (itemId: number) =>
request<{ status: string }>(`/maintenance/items/${itemId}`, {
method: 'DELETE',
}),
// Camera
getCameraStreamUrl: (printerId: number, fps = 10) =>
@@ -1903,6 +2005,64 @@ export const api = {
body: JSON.stringify({ queue_item_ids: queueItemIds }),
}),
// Project Attachments
uploadProjectAttachment: async (projectId: number, file: File): Promise<{
status: string;
filename: string;
original_name: string;
attachments: ProjectAttachment[];
}> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
getProjectAttachmentUrl: (projectId: number, filename: string) =>
`${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
deleteProjectAttachment: (projectId: number, filename: string) =>
request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(
`/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
),
// BOM (Bill of Materials)
getProjectBOM: (projectId: number) =>
request<BOMItem[]>(`/projects/${projectId}/bom`),
createBOMItem: (projectId: number, data: BOMItemCreate) =>
request<BOMItem>(`/projects/${projectId}/bom`, {
method: 'POST',
body: JSON.stringify(data),
}),
updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) =>
request<BOMItem>(`/projects/${projectId}/bom/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteBOMItem: (projectId: number, itemId: number) =>
request<{ status: string; message: string }>(`/projects/${projectId}/bom/${itemId}`, {
method: 'DELETE',
}),
// Templates
getTemplates: () => request<ProjectListItem[]>('/projects/templates/'),
createTemplateFromProject: (projectId: number) =>
request<Project>(`/projects/${projectId}/create-template`, { method: 'POST' }),
createProjectFromTemplate: (templateId: number, name?: string) =>
request<Project>(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, {
method: 'POST',
}),
// Timeline
getProjectTimeline: (projectId: number, limit = 50) =>
request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
// API Keys
getAPIKeys: () => request<APIKey[]>('/api-keys/'),
createAPIKey: (data: APIKeyCreate) =>
+176 -23
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Wrench,
@@ -32,6 +32,7 @@ import {
Settings,
Filter,
CircleDot,
Printer,
} from 'lucide-react';
import { api } from '../api/client';
import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
@@ -401,13 +402,17 @@ function SettingsSection({
onAddType,
onUpdateType,
onDeleteType,
onAssignType,
onRemoveItem,
}: {
overview: PrinterMaintenanceOverview[] | undefined;
types: MaintenanceType[];
onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void;
onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }) => void;
onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void;
onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void;
onDeleteType: (id: number) => void;
onAssignType: (printerId: number, typeId: number) => void;
onRemoveItem: (itemId: number) => void;
}) {
const [editingInterval, setEditingInterval] = useState<number | null>(null);
const [intervalInput, setIntervalInput] = useState('');
@@ -417,6 +422,33 @@ function SettingsSection({
const [newTypeInterval, setNewTypeInterval] = useState('100');
const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours');
const [newTypeIcon, setNewTypeIcon] = useState('Wrench');
const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
const [expandedType, setExpandedType] = useState<number | null>(null);
// Get unique printers from overview
const printers = useMemo(() => {
if (!overview) return [];
return overview.map(o => ({ id: o.printer_id, name: o.printer_name }));
}, [overview]);
// Get which printers have a specific maintenance type assigned
const getAssignedPrinters = (typeId: number) => {
if (!overview) return [];
return overview
.filter(p => p.maintenance_items.some(item => item.maintenance_type_id === typeId))
.map(p => ({
printerId: p.printer_id,
printerName: p.printer_name,
itemId: p.maintenance_items.find(item => item.maintenance_type_id === typeId)?.id,
}));
};
// Get printers that DON'T have a specific type assigned
const getUnassignedPrinters = (typeId: number) => {
if (!overview) return [];
const assignedIds = new Set(getAssignedPrinters(typeId).map(p => p.printerId));
return printers.filter(p => !assignedIds.has(p.id));
};
// Edit type state
const [editingType, setEditingType] = useState<MaintenanceType | null>(null);
@@ -460,20 +492,33 @@ function SettingsSection({
const handleAddType = (e: React.FormEvent) => {
e.preventDefault();
if (newTypeName.trim() && parseFloat(newTypeInterval) > 0) {
if (newTypeName.trim() && parseFloat(newTypeInterval) > 0 && selectedPrinters.size > 0) {
onAddType({
name: newTypeName.trim(),
default_interval_hours: parseFloat(newTypeInterval),
interval_type: newTypeIntervalType,
icon: newTypeIcon,
});
}, Array.from(selectedPrinters));
setNewTypeName('');
setNewTypeInterval('100');
setNewTypeIntervalType('hours');
setSelectedPrinters(new Set());
setShowAddType(false);
}
};
const togglePrinterSelection = (printerId: number) => {
setSelectedPrinters(prev => {
const next = new Set(prev);
if (next.has(printerId)) {
next.delete(printerId);
} else {
next.add(printerId);
}
return next;
});
};
const printerItems = overview?.map(p => ({
printerId: p.printer_id,
printerName: p.printer_name,
@@ -570,14 +615,37 @@ function SettingsSection({
})}
</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" onClick={() => setShowAddType(false)}>
Cancel
</Button>
<Button type="submit" disabled={!newTypeName.trim()}>
Add Type
</Button>
</div>
{/* Printer selection */}
<div className="mt-4">
<label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
<div className="flex flex-wrap gap-2">
{printers.map(p => (
<button
key={p.id}
type="button"
onClick={() => togglePrinterSelection(p.id)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
selectedPrinters.has(p.id)
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
}`}
>
{p.name}
</button>
))}
</div>
{selectedPrinters.size === 0 && (
<p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
Cancel
</Button>
<Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
Add Type
</Button>
</div>
</form>
</CardContent>
@@ -674,6 +742,10 @@ function SettingsSection({
);
}
const assignedPrinters = getAssignedPrinters(type.id);
const unassignedPrinters = getUnassignedPrinters(type.id);
const isExpanded = expandedType === type.id;
return (
<div key={type.id} className="bg-bambu-dark-secondary rounded-xl p-4 border border-bambu-green/30">
<div className="flex items-center gap-3">
@@ -692,6 +764,19 @@ function SettingsSection({
{formatIntervalLabel(type.default_interval_hours, intervalType)}
</div>
</div>
<button
onClick={() => setExpandedType(isExpanded ? null : type.id)}
className={`px-2 py-1 rounded-lg border transition-colors flex items-center gap-1 ${
assignedPrinters.length > 0
? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
: 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
}`}
title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
>
<Printer className="w-3 h-3" />
<span className="text-xs font-medium">{assignedPrinters.length}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</button>
<button
onClick={() => startEditType(type)}
className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
@@ -709,6 +794,48 @@ function SettingsSection({
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Printer assignment management */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
{assignedPrinters.length === 0 ? (
<p className="text-xs text-orange-400">No printers assigned</p>
) : (
<div className="flex flex-wrap gap-1 mb-2">
{assignedPrinters.map(p => (
<span
key={p.printerId}
className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-dark rounded text-xs text-white"
>
{p.printerName}
<button
onClick={() => p.itemId && onRemoveItem(p.itemId)}
className="hover:text-red-400 ml-1"
title="Remove from this printer"
>
×
</button>
</span>
))}
</div>
)}
{unassignedPrinters.length > 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-bambu-gray mr-1">Add:</span>
{unassignedPrinters.map(p => (
<button
key={p.id}
onClick={() => onAssignType(p.id, type.id)}
className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
>
+ {p.name}
</button>
))}
</div>
)}
</div>
)}
</div>
);
})}
@@ -850,17 +977,8 @@ export function MaintenancePage() {
},
});
const addTypeMutation = useMutation({
mutationFn: api.createMaintenanceType,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type added');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// addTypeMutation removed - we now handle type creation with printer assignment
// directly in onAddType callback
const updateTypeMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<{ name: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon: string }> }) =>
@@ -900,6 +1018,29 @@ export function MaintenancePage() {
},
});
const assignTypeMutation = useMutation({
mutationFn: ({ printerId, typeId }: { printerId: number; typeId: number }) =>
api.assignMaintenanceType(printerId, typeId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Printer assigned');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const removeItemMutation = useMutation({
mutationFn: api.removeMaintenanceItem,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Printer removed');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handlePerform = (id: number) => {
performMutation.mutate({ id });
};
@@ -1002,9 +1143,21 @@ export function MaintenancePage() {
onUpdateInterval={(id, data) =>
updateMutation.mutate({ id, data })
}
onAddType={(data) => addTypeMutation.mutate(data)}
onAddType={async (data, printerIds) => {
// Create the type first, then assign to selected printers
const newType = await api.createMaintenanceType(data);
// Assign to each selected printer
for (const printerId of printerIds) {
await api.assignMaintenanceType(printerId, newType.id);
}
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type added');
}}
onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
/>
)}
</div>
+66 -17
View File
@@ -596,6 +596,7 @@ function PrinterCard({
const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteArchives, setDeleteArchives] = useState(true);
const [showEditModal, setShowEditModal] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
const [showMQTTDebug, setShowMQTTDebug] = useState(false);
@@ -685,9 +686,11 @@ function PrinterCard({
const shouldHide = hideIfDisconnected && isConnected === false;
const deleteMutation = useMutation({
mutationFn: () => api.deletePrinter(printer.id),
mutationFn: (options: { deleteArchives: boolean }) =>
api.deletePrinter(printer.id, options.deleteArchives),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
},
});
@@ -919,17 +922,64 @@ function PrinterCard({
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Printer"
message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardContent>
<div className="flex items-start gap-3 mb-4">
<div className="p-2 rounded-full bg-red-500/20">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Printer</h3>
<p className="text-sm text-bambu-gray mt-1">
Are you sure you want to delete "{printer.name}"? This will remove all connection settings.
</p>
</div>
</div>
<div className="bg-bambu-dark rounded-lg p-3 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={deleteArchives}
onChange={(e) => setDeleteArchives(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-bambu-gray bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<div>
<span className="text-sm text-white">Delete print archives</span>
<p className="text-xs text-bambu-gray mt-0.5">
{deleteArchives
? 'All print history for this printer will be permanently deleted.'
: 'Print history will be kept but no longer associated with this printer.'}
</p>
</div>
</label>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
>
Cancel
</Button>
<Button
variant="danger"
onClick={() => {
deleteMutation.mutate({ deleteArchives });
setShowDeleteConfirm(false);
setDeleteArchives(true);
}}
>
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Status */}
@@ -1039,11 +1089,10 @@ function PrinterCard({
{/* Temperatures */}
{status.temperatures && viewMode === 'expanded' && (() => {
// Determine heater states (target > 30°C means heater is actively set)
const nozzleHeating = (status.temperatures.nozzle_target || 0) > 30 ||
(status.temperatures.nozzle_2_target || 0) > 30;
const bedHeating = (status.temperatures.bed_target || 0) > 30;
const chamberHeating = (status.temperatures.chamber_target || 0) > 30;
// Use actual heater states from MQTT stream
const nozzleHeating = status.temperatures.nozzle_heating || status.temperatures.nozzle_2_heating || false;
const bedHeating = status.temperatures.bed_heating || false;
const chamberHeating = status.temperatures.chamber_heating || false;
return (
<div className="grid grid-cols-3 gap-3">
File diff suppressed because it is too large Load Diff
+242 -93
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
FolderKanban,
@@ -9,10 +10,14 @@ import {
Archive,
ListTodo,
Package,
Clock,
CheckCircle2,
AlertTriangle,
ChevronRight,
MoreVertical,
} from 'lucide-react';
import { api } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
@@ -36,12 +41,15 @@ interface ProjectModalProps {
isLoading: boolean;
}
function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
const [name, setName] = useState(project?.name || '');
const [description, setDescription] = useState(project?.description || '');
const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
const [status, setStatus] = useState(project?.status || 'active');
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 handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -50,6 +58,9 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
description: description.trim() || undefined,
color,
target_count: targetCount ? parseInt(targetCount, 10) : undefined,
tags: tags.trim() || undefined,
due_date: dueDate || undefined,
priority,
...(project && { status }),
});
};
@@ -124,6 +135,50 @@ function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-white mb-1">
Tags (comma-separated)
</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
placeholder="e.g., voron, functional, gift"
/>
</div>
{/* Due Date and Priority in a row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Due Date
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">
Priority
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
{project && (
<div>
<label className="block text-sm font-medium text-white mb-1">
@@ -172,74 +227,121 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
const progressPercent = project.progress_percent ?? 0;
const isCompleted = project.status === 'completed';
const isArchived = project.status === 'archived';
const [showActions, setShowActions] = useState(false);
// Status icon and color
const getStatusConfig = () => {
if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
};
const statusConfig = getStatusConfig();
return (
<Card className="hover:border-bambu-gray/30 transition-colors cursor-pointer" onClick={onClick}>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div>
<h3 className="font-medium text-white">{project.name}</h3>
<div
className="group relative bg-bambu-card rounded-xl border border-bambu-dark-tertiary hover:border-bambu-gray/40 transition-all cursor-pointer overflow-hidden"
onClick={onClick}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 w-1 h-full"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div className="p-5 pl-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
<statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-white truncate">{project.name}</h3>
{isCompleted && (
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
Done
</span>
)}
{isArchived && (
<span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
Archived
</span>
)}
</div>
{project.description && (
<p className="text-sm text-bambu-gray/70 mt-0.5 line-clamp-1">
<p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
{project.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{isCompleted && (
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded">
Completed
</span>
{/* Actions menu */}
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
onClick={() => setShowActions(!showActions)}
>
<MoreVertical className="w-4 h-4" />
</button>
{showActions && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
<div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onEdit(); setShowActions(false); }}
>
<Edit3 className="w-4 h-4" />
Edit
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(); setShowActions(false); }}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</>
)}
{isArchived && (
<span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded">
Archived
</span>
)}
<Button variant="ghost" size="sm" onClick={onEdit} className="p-1">
<Edit3 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} className="p-1 text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Progress bar */}
{project.target_count && (
<div className="mb-3">
<div className="flex justify-between text-xs text-bambu-gray mb-1">
<span>{project.archive_count} / {project.target_count} prints</span>
<span>{progressPercent.toFixed(0)}%</span>
{/* Progress section */}
{project.target_count ? (
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-bambu-gray">Progress</span>
<span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
{project.archive_count} / {project.target_count}
</span>
</div>
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300"
className="h-full transition-all duration-500 ease-out rounded-full"
style={{
width: `${Math.min(progressPercent, 100)}%`,
backgroundColor: progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
<div className="text-right text-xs text-bambu-gray/60 mt-1">
{progressPercent.toFixed(0)}% complete
</div>
</div>
)}
) : null}
{/* Archive thumbnails */}
{/* Archive thumbnails grid */}
{project.archives && project.archives.length > 0 && (
<div className="mb-3">
<div className="flex gap-2">
{project.archives.slice(0, 5).map((archive) => (
<a
<div className="mb-4">
<div className="flex gap-1.5">
{project.archives.slice(0, 4).map((archive) => (
<div
key={archive.id}
href={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
onClick={(e) => e.stopPropagation()}
className="relative w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
className="relative w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 overflow-hidden border border-bambu-dark-tertiary"
title={archive.print_name || 'Unknown'}
>
{archive.thumbnail_path ? (
@@ -249,43 +351,49 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Package className="w-6 h-6" />
<div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
<Package className="w-5 h-5" />
</div>
)}
{archive.status === 'failed' && (
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<span className="text-white text-xs font-bold">✗</span>
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-white" />
</div>
)}
</a>
</div>
))}
{project.archive_count > 5 && (
<div className="w-14 h-14 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-sm text-bambu-gray border border-bambu-dark-tertiary">
+{project.archive_count - 5}
{project.archive_count > 4 && (
<div className="w-12 h-12 rounded-lg bg-bambu-dark flex-shrink-0 flex items-center justify-center text-xs text-bambu-gray border border-bambu-dark-tertiary">
+{project.archive_count - 4}
</div>
)}
</div>
</div>
)}
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-bambu-gray">
<div className="flex items-center gap-1" title="Archives">
<Archive className="w-4 h-4" />
<span>{project.archive_count}</span>
</div>
<div className="flex items-center gap-1" title="Queued">
<ListTodo className="w-4 h-4" />
<span>{project.queue_count}</span>
{/* Stats footer */}
<div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
<div className="flex items-center gap-4 text-xs text-bambu-gray">
<div className="flex items-center gap-1.5" title="Completed prints">
<Archive className="w-3.5 h-3.5" />
<span>{project.archive_count}</span>
</div>
{project.queue_count > 0 && (
<div className="flex items-center gap-1.5 text-blue-400" title="In queue">
<ListTodo className="w-3.5 h-3.5" />
<span>{project.queue_count}</span>
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export function ProjectsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [showModal, setShowModal] = useState(false);
@@ -350,8 +458,8 @@ export function ProjectsPage() {
};
const handleClick = (project: ProjectListItem) => {
// Open edit modal when clicking on card
handleEdit(project);
// Navigate to project detail page
navigate(`/projects/${project.id}`);
};
const handleDeleteClick = (id: number) => {
@@ -364,54 +472,95 @@ export function ProjectsPage() {
}
};
// Count projects by status for filter badges
const projectCounts = projects?.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1;
acc.all = (acc.all || 0) + 1;
return acc;
}, {} as Record<string, number>) || {};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FolderKanban className="w-6 h-6 text-bambu-green" />
<h1 className="text-2xl font-bold text-white">Projects</h1>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-bambu-green/10 rounded-lg">
<FolderKanban className="w-6 h-6 text-bambu-green" />
</div>
Projects
</h1>
<p className="text-sm text-bambu-gray mt-1 ml-14">
Organize and track your 3D printing projects
</p>
</div>
<Button onClick={() => setShowModal(true)}>
<Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
{/* Filters */}
<div className="flex gap-2">
{['active', 'completed', 'archived', 'all'].map((status) => (
{/* Filter tabs */}
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
{[
{ key: 'active', label: 'Active', icon: Clock },
{ key: 'completed', label: 'Completed', icon: CheckCircle2 },
{ key: 'archived', label: 'Archived', icon: Archive },
{ key: 'all', label: 'All', icon: FolderKanban },
].map(({ key, label, icon: Icon }) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
statusFilter === status
? 'bg-bambu-green text-white'
: 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
key={key}
onClick={() => setStatusFilter(key)}
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
statusFilter === key
? 'bg-bambu-card text-white shadow-sm'
: 'text-bambu-gray hover:text-white'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
<Icon className="w-4 h-4" />
<span>{label}</span>
{projectCounts[key] > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
}`}>
{projectCounts[key]}
</span>
)}
</button>
))}
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
<p className="text-sm text-bambu-gray">Loading projects...</p>
</div>
</div>
) : projects?.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<FolderKanban className="w-12 h-12 text-bambu-gray/50 mx-auto mb-4" />
<p className="text-bambu-gray">No projects found</p>
<p className="text-bambu-gray/70 text-sm mt-1">
Create a project to group related prints together
</p>
</CardContent>
</Card>
<div className="flex flex-col items-center justify-center py-20 px-4">
<div className="p-4 bg-bambu-dark rounded-2xl mb-4">
<FolderKanban className="w-12 h-12 text-bambu-gray/50" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
</h3>
<p className="text-bambu-gray text-center max-w-md mb-6">
{statusFilter === 'all'
? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
: `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
}
</p>
{statusFilter === 'all' && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Your First Project
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{projects?.map((project) => (
<ProjectCard
key={project.id}