mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
- 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:
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user