maziggy 5 bulan lalu
induk
melakukan
e47ef36630

+ 104 - 0
backend/app/api/routes/cloud.py

@@ -24,6 +24,9 @@ from backend.app.schemas.cloud import (
     SlicerSettingsResponse,
     SlicerSettingsResponse,
     SlicerSetting,
     SlicerSetting,
     CloudDevice,
     CloudDevice,
+    SlicerSettingCreate,
+    SlicerSettingUpdate,
+    SlicerSettingDeleteResponse,
 )
 )
 
 
 router = APIRouter(prefix="/cloud", tags=["cloud"])
 router = APIRouter(prefix="/cloud", tags=["cloud"])
@@ -287,3 +290,104 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
         raise HTTPException(status_code=401, detail="Authentication expired")
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/settings")
+async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
+    """
+    Create a new slicer preset/setting.
+
+    Creates a new preset on Bambu Cloud. The preset inherits from a base preset
+    and only stores the delta (modified values).
+
+    Type should be: 'filament', 'print', or 'printer'
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.create_setting(
+            preset_type=request.type,
+            name=request.name,
+            base_id=request.base_id,
+            setting=request.setting,
+            version=request.version,
+        )
+        return data
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/settings/{setting_id}")
+async def update_setting(
+    setting_id: str,
+    request: SlicerSettingUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """
+    Update an existing slicer preset/setting.
+
+    Updates the preset's name and/or settings on Bambu Cloud.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        data = await cloud.update_setting(
+            setting_id=setting_id,
+            name=request.name,
+            setting=request.setting,
+        )
+        return data
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
+async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
+    """
+    Delete a slicer preset/setting.
+
+    Removes the preset from Bambu Cloud. This cannot be undone.
+    """
+    token, _ = await get_stored_token(db)
+    if not token:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        raise HTTPException(status_code=401, detail="Not authenticated")
+
+    try:
+        result = await cloud.delete_setting(setting_id)
+        return SlicerSettingDeleteResponse(
+            success=result.get("success", True),
+            message=result.get("message", "Setting deleted"),
+        )
+    except BambuCloudAuthError:
+        await clear_token(db)
+        raise HTTPException(status_code=401, detail="Authentication expired")
+    except BambuCloudError as e:
+        raise HTTPException(status_code=500, detail=str(e))

+ 177 - 0
backend/app/api/routes/kprofiles.py

@@ -9,11 +9,14 @@ from sqlalchemy import select
 
 
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.schemas.kprofile import (
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfile,
     KProfileCreate,
     KProfileCreate,
     KProfileDelete,
     KProfileDelete,
     KProfilesResponse,
     KProfilesResponse,
+    KProfileNote,
+    KProfileNoteResponse,
 )
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
@@ -170,6 +173,64 @@ async def set_kprofile(
     return {"success": True, "message": message}
     return {"success": True, "message": message}
 
 
 
 
+@router.post("/batch", response_model=dict)
+async def set_kprofiles_batch(
+    printer_id: int,
+    profiles: list[KProfileCreate],
+    db: AsyncSession = Depends(get_db),
+):
+    """Create multiple K-profiles in a single command (for dual-nozzle).
+
+    This sends all profiles in one MQTT command, which is more reliable
+    for dual-nozzle printers that may not handle sequential commands well.
+
+    Args:
+        printer_id: ID of the printer
+        profiles: List of K-profiles to set
+    """
+    if not profiles:
+        raise HTTPException(400, "No profiles provided")
+
+    logger.info(f"[API] set_kprofiles_batch: printer={printer_id}, {len(profiles)} profiles")
+    for p in profiles:
+        logger.info(f"  - extruder_id={p.extruder_id}, name={p.name}, k_value={p.k_value}")
+
+    # Check printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Get MQTT client for printer
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    # Build list of profile dicts for batch command
+    profile_dicts = [
+        {
+            "filament_id": p.filament_id,
+            "name": p.name,
+            "k_value": p.k_value,
+            "nozzle_id": p.nozzle_id,
+            "extruder_id": p.extruder_id,
+            "setting_id": p.setting_id,
+            "slot_id": p.slot_id,
+        }
+        for p in profiles
+    ]
+
+    # Get nozzle_diameter from first profile (all should have same)
+    nozzle_diameter = profiles[0].nozzle_diameter
+
+    success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
+
+    if not success:
+        raise HTTPException(500, "Failed to send K-profiles batch command")
+
+    return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
+
+
 @router.delete("/", response_model=dict)
 @router.delete("/", response_model=dict)
 async def delete_kprofile(
 async def delete_kprofile(
     printer_id: int,
     printer_id: int,
@@ -194,6 +255,10 @@ async def delete_kprofile(
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
 
 
     # Send the delete command to printer
     # Send the delete command to printer
+    logger.info(
+        f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
+        f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
+    )
     success = client.delete_kprofile(
     success = client.delete_kprofile(
         cali_idx=profile.slot_id,
         cali_idx=profile.slot_id,
         filament_id=profile.filament_id,
         filament_id=profile.filament_id,
@@ -207,3 +272,115 @@ async def delete_kprofile(
         raise HTTPException(500, "Failed to send K-profile delete command")
         raise HTTPException(500, "Failed to send K-profile delete command")
 
 
     return {"success": True, "message": "K-profile deleted successfully"}
     return {"success": True, "message": "K-profile deleted successfully"}
+
+
+@router.get("/notes", response_model=KProfileNoteResponse)
+async def get_kprofile_notes(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all K-profile notes for a printer.
+
+    Notes are stored locally since printers don't support notes.
+
+    Args:
+        printer_id: ID of the printer
+    """
+    # Check printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Get all notes for this printer
+    result = await db.execute(
+        select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id)
+    )
+    notes = result.scalars().all()
+
+    # Return as a dictionary mapping setting_id -> note
+    return KProfileNoteResponse(
+        notes={note.setting_id: note.note for note in notes}
+    )
+
+
+@router.put("/notes", response_model=dict)
+async def set_kprofile_note(
+    printer_id: int,
+    note_data: KProfileNote,
+    db: AsyncSession = Depends(get_db),
+):
+    """Set or update a note for a K-profile.
+
+    Args:
+        printer_id: ID of the printer
+        note_data: The note data (setting_id and note content)
+    """
+    # Check printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Find existing note or create new one
+    result = await db.execute(
+        select(KProfileNoteModel).where(
+            KProfileNoteModel.printer_id == printer_id,
+            KProfileNoteModel.setting_id == note_data.setting_id,
+        )
+    )
+    existing_note = result.scalar_one_or_none()
+
+    if note_data.note.strip():
+        # Save or update note
+        if existing_note:
+            existing_note.note = note_data.note
+        else:
+            new_note = KProfileNoteModel(
+                printer_id=printer_id,
+                setting_id=note_data.setting_id,
+                note=note_data.note,
+            )
+            db.add(new_note)
+        await db.commit()
+        return {"success": True, "message": "Note saved"}
+    else:
+        # Delete note if empty
+        if existing_note:
+            await db.delete(existing_note)
+            await db.commit()
+        return {"success": True, "message": "Note deleted"}
+
+
+@router.delete("/notes/{setting_id}", response_model=dict)
+async def delete_kprofile_note(
+    printer_id: int,
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a note for a K-profile.
+
+    Args:
+        printer_id: ID of the printer
+        setting_id: The setting_id of the K-profile
+    """
+    # Check printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Find and delete the note
+    result = await db.execute(
+        select(KProfileNoteModel).where(
+            KProfileNoteModel.printer_id == printer_id,
+            KProfileNoteModel.setting_id == setting_id,
+        )
+    )
+    existing_note = result.scalar_one_or_none()
+
+    if existing_note:
+        await db.delete(existing_note)
+        await db.commit()
+
+    return {"success": True, "message": "Note deleted"}

+ 1 - 0
backend/app/api/routes/print_queue.py

@@ -30,6 +30,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     if item.archive:
     if item.archive:
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_name = item.archive.print_name or item.archive.filename
         response.archive_thumbnail = item.archive.thumbnail_path
         response.archive_thumbnail = item.archive.thumbnail_path
+        response.print_time_seconds = item.archive.print_time_seconds
     if item.printer:
     if item.printer:
         response.printer_name = item.printer.name
         response.printer_name = item.printer.name
     return response
     return response

+ 1 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note  # noqa: F401
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)

+ 2 - 0
backend/app/models/__init__.py

@@ -4,6 +4,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
 from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
+from backend.app.models.kprofile_note import KProfileNote
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -14,4 +15,5 @@ __all__ = [
     "MaintenanceType",
     "MaintenanceType",
     "PrinterMaintenance",
     "PrinterMaintenance",
     "MaintenanceHistory",
     "MaintenanceHistory",
+    "KProfileNote",
 ]
 ]

+ 36 - 0
backend/app/models/kprofile_note.py

@@ -0,0 +1,36 @@
+"""Model for K-profile notes stored locally (not on printer)."""
+
+from datetime import datetime
+from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class KProfileNote(Base):
+    """Notes for K-profiles stored locally since printers don't support notes."""
+
+    __tablename__ = "kprofile_notes"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    # setting_id is the unique identifier for a K-profile on the printer
+    setting_id: Mapped[str] = mapped_column(String(100))
+    note: Mapped[str] = mapped_column(Text, default="")
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+    # Relationship to printer
+    printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
+
+    # Composite index for efficient lookups
+    __table_args__ = (
+        Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),
+    )
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 4 - 0
backend/app/models/printer.py

@@ -39,9 +39,13 @@ class Printer(Base):
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         back_populates="printer", cascade="all, delete-orphan"
     )
     )
+    kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402

+ 38 - 0
backend/app/schemas/cloud.py

@@ -57,3 +57,41 @@ class CloudDevice(BaseModel):
     dev_model_name: Optional[str] = None
     dev_model_name: Optional[str] = None
     dev_product_name: Optional[str] = None
     dev_product_name: Optional[str] = None
     online: bool = False
     online: bool = False
+
+
+class SlicerSettingCreate(BaseModel):
+    """Request to create a new slicer preset."""
+    type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
+    name: str = Field(..., description="Display name for the preset")
+    base_id: str = Field(..., description="Base preset ID to inherit from")
+    version: str = Field(default="2.0.0.0", description="Version string for the preset")
+    setting: dict = Field(default_factory=dict, description="Setting key-value pairs (delta from base)")
+
+
+class SlicerSettingUpdate(BaseModel):
+    """Request to update an existing slicer preset."""
+    name: Optional[str] = Field(None, description="New display name")
+    setting: Optional[dict] = Field(None, description="Setting key-value pairs to update")
+
+
+class SlicerSettingDetail(BaseModel):
+    """Detailed slicer setting/preset response."""
+    message: Optional[str] = None
+    code: Optional[str] = None
+    error: Optional[str] = None
+    public: bool = False
+    version: Optional[str] = None
+    type: str
+    name: str
+    update_time: Optional[str] = None
+    nickname: Optional[str] = None
+    base_id: Optional[str] = None
+    setting: dict = Field(default_factory=dict)
+    filament_id: Optional[str] = None
+    setting_id: Optional[str] = None  # For response after create
+
+
+class SlicerSettingDeleteResponse(BaseModel):
+    """Response from deleting a preset."""
+    success: bool
+    message: str

+ 13 - 0
backend/app/schemas/kprofile.py

@@ -51,3 +51,16 @@ class KProfileDelete(BaseModel):
     nozzle_diameter: str  # e.g., "0.4"
     nozzle_diameter: str  # e.g., "0.4"
     filament_id: str  # Bambu filament identifier
     filament_id: str  # Bambu filament identifier
     setting_id: str | None = None  # Setting ID (for X1C series)
     setting_id: str | None = None  # Setting ID (for X1C series)
+
+
+class KProfileNote(BaseModel):
+    """Schema for K-profile notes (stored locally, not on printer)."""
+
+    setting_id: str  # Unique identifier for the K-profile
+    note: str  # The note content
+
+
+class KProfileNoteResponse(BaseModel):
+    """Response containing notes for K-profiles."""
+
+    notes: dict[str, str]  # mapping of setting_id -> note

+ 1 - 0
backend/app/schemas/print_queue.py

@@ -48,6 +48,7 @@ class PrintQueueItemResponse(BaseModel):
     archive_name: str | None = None
     archive_name: str | None = None
     archive_thumbnail: str | None = None
     archive_thumbnail: str | None = None
     printer_name: str | None = None
     printer_name: str | None = None
+    print_time_seconds: int | None = None  # Estimated print time from archive
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 3 - 1
backend/app/services/archive.py

@@ -573,8 +573,10 @@ class ArchiveService:
                 name_conditions.append(PrintArchive.print_name.ilike(print_name))
                 name_conditions.append(PrintArchive.print_name.ilike(print_name))
             if makerworld_model_id:
             if makerworld_model_id:
                 # Match by MakerWorld model ID stored in extra_data
                 # Match by MakerWorld model ID stored in extra_data
+                # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
+                from sqlalchemy import func, cast, String
                 name_conditions.append(
                 name_conditions.append(
-                    PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
+                    func.json_extract(PrintArchive.extra_data, '$.makerworld_model_id') == str(makerworld_model_id)
                 )
                 )
 
 
             if name_conditions:
             if name_conditions:

+ 163 - 0
backend/app/services/bambu_cloud.py

@@ -220,6 +220,169 @@ class BambuCloudService:
         except httpx.RequestError as e:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
             raise BambuCloudError(f"Request failed: {e}")
 
 
+    async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict:
+        """
+        Create a new slicer preset/setting.
+
+        Args:
+            preset_type: Type of preset - "filament", "print", or "printer"
+            name: Display name for the preset
+            base_id: Base preset ID to inherit from (e.g., "GFSA00")
+            setting: Dict of setting key-value pairs (only modified values from base)
+            version: Version string for the preset (default: "2.0.0.0")
+
+        Returns:
+            Created preset data including the new setting_id
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            # Add timestamp if not present
+            import time
+            if "updated_time" not in setting:
+                setting["updated_time"] = str(int(time.time()))
+
+            payload = {
+                "type": preset_type,
+                "name": name,
+                "version": version,
+                "base_id": base_id,
+                "setting": setting,
+            }
+
+            response = await self._client.post(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting",
+                headers=self._get_headers(),
+                json=payload
+            )
+
+            data = response.json()
+
+            if response.status_code in (200, 201):
+                return data
+
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to create setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
+        """
+        Update an existing slicer preset/setting.
+
+        Note: Bambu Cloud API doesn't support true updates. Instead, we:
+        1. Fetch the current setting metadata (type, base_id, version)
+        2. Use the provided settings as the new complete settings (NOT merged)
+        3. Delete the old setting first (to avoid name conflicts)
+        4. Create a new setting via POST
+
+        Args:
+            setting_id: ID of the preset to update
+            name: New display name (optional)
+            setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
+
+        Returns:
+            Updated preset data with new setting_id
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            # Fetch current setting to get metadata (type, base_id, version)
+            current = await self.get_setting_detail(setting_id)
+            preset_type = current.get("type", "filament")
+
+            # Use provided settings directly (complete replacement, not merge)
+            # This allows the frontend to edit the full settings JSON
+            if setting is not None:
+                updated_setting = setting.copy()
+            else:
+                updated_setting = current.get("setting", {}).copy()
+
+            # Extract name from settings_id field in the JSON, or use provided name, or fall back to current
+            # The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
+            settings_id_key = {
+                "filament": "filament_settings_id",
+                "print": "print_settings_id",
+                "printer": "printer_settings_id",
+            }.get(preset_type, "filament_settings_id")
+
+            settings_id_value = updated_setting.get(settings_id_key, "")
+            if settings_id_value:
+                # Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
+                updated_name = settings_id_value.strip('"')
+            elif name is not None:
+                updated_name = name
+            else:
+                updated_name = current.get("name", "Untitled")
+
+            # Update the timestamp
+            import time
+            updated_setting["updated_time"] = str(int(time.time()))
+
+            # Ensure settings_id field matches the name
+            updated_setting[settings_id_key] = f'"{updated_name}"'
+
+            # Delete the old setting FIRST to avoid name conflicts
+            await self.delete_setting(setting_id)
+
+            # Create new setting via POST
+            payload = {
+                "type": preset_type,
+                "name": updated_name,
+                "version": current.get("version", "2.0.0.0"),
+                "base_id": current.get("base_id", ""),
+                "setting": updated_setting,
+            }
+
+            response = await self._client.post(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting",
+                headers=self._get_headers(),
+                json=payload
+            )
+
+            data = response.json()
+
+            if response.status_code == 200:
+                return data
+
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to update setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
+    async def delete_setting(self, setting_id: str) -> dict:
+        """
+        Delete a slicer preset/setting.
+
+        Args:
+            setting_id: ID of the preset to delete
+
+        Returns:
+            Deletion confirmation
+        """
+        if not self.is_authenticated:
+            raise BambuCloudAuthError("Not authenticated")
+
+        try:
+            response = await self._client.delete(
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers()
+            )
+
+            if response.status_code in (200, 204):
+                return {"success": True, "message": "Setting deleted"}
+
+            data = response.json() if response.content else {}
+            error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
+            raise BambuCloudError(f"Failed to delete setting: {error_msg}")
+
+        except httpx.RequestError as e:
+            raise BambuCloudError(f"Request failed: {e}")
+
     async def get_devices(self) -> dict:
     async def get_devices(self) -> dict:
         """Get list of bound devices."""
         """Get list of bound devices."""
         if not self.is_authenticated:
         if not self.is_authenticated:

+ 178 - 111
backend/app/services/bambu_mqtt.py

@@ -1,3 +1,12 @@
+"""Bambu Lab MQTT communication service.
+
+IMPORTANT: Always use qos=1 for all MQTT publish calls!
+The printer ignores qos=0 messages when busy broadcasting status updates.
+Using qos=1 ensures the printer acknowledges and processes our commands immediately.
+This was discovered when K-profile requests with qos=0 took 20-30 seconds,
+but with qos=1 they respond instantly.
+"""
+
 import json
 import json
 import ssl
 import ssl
 import asyncio
 import asyncio
@@ -1411,7 +1420,7 @@ class BambuMQTTClient:
         """Request full status update from printer."""
         """Request full status update from printer."""
         if self._client:
         if self._client:
             message = {"pushing": {"command": "pushall"}}
             message = {"pushing": {"command": "pushall"}}
-            self._client.publish(self.topic_publish, json.dumps(message))
+            self._client.publish(self.topic_publish, json.dumps(message), qos=1)
 
 
     def request_status_update(self) -> bool:
     def request_status_update(self) -> bool:
         """Request a full status update from the printer (public API).
         """Request a full status update from the printer (public API).
@@ -1441,7 +1450,7 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
             logger.debug(f"[{self.serial_number}] Requesting accessories info")
             logger.debug(f"[{self.serial_number}] Requesting accessories info")
-            self._client.publish(self.topic_publish, json.dumps(message))
+            self._client.publish(self.topic_publish, json.dumps(message), qos=1)
 
 
     def _prime_kprofile_request(self):
     def _prime_kprofile_request(self):
         """Send a priming K-profile request on connect.
         """Send a priming K-profile request on connect.
@@ -1460,7 +1469,7 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
             logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
             logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
-            self._client.publish(self.topic_publish, json.dumps(command))
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
 
     def connect(self, loop: asyncio.AbstractEventLoop | None = None):
     def connect(self, loop: asyncio.AbstractEventLoop | None = None):
         """Connect to the printer MQTT broker.
         """Connect to the printer MQTT broker.
@@ -1516,7 +1525,7 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
             logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
             logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
-            self._client.publish(self.topic_publish, json.dumps(command))
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             return True
             return True
         return False
         return False
 
 
@@ -1529,7 +1538,7 @@ class BambuMQTTClient:
                     "sequence_id": "0"
                     "sequence_id": "0"
                 }
                 }
             }
             }
-            self._client.publish(self.topic_publish, json.dumps(command))
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             logger.info(f"[{self.serial_number}] Sent stop print command")
             logger.info(f"[{self.serial_number}] Sent stop print command")
             return True
             return True
         return False
         return False
@@ -1763,7 +1772,7 @@ class BambuMQTTClient:
                     direction="out",
                     direction="out",
                     payload=command,
                     payload=command,
                 ))
                 ))
-            self._client.publish(self.topic_publish, json.dumps(command))
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
 
     def enable_logging(self, enabled: bool = True):
     def enable_logging(self, enabled: bool = True):
         """Enable or disable MQTT message logging."""
         """Enable or disable MQTT message logging."""
@@ -1785,13 +1794,50 @@ class BambuMQTTClient:
 
 
     def _handle_kprofile_response(self, data: dict):
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         """Handle K-profile response from printer."""
+        response_nozzle = data.get("nozzle_diameter")
+        response_seq_id = data.get("sequence_id", "?")
         filaments = data.get("filaments", [])
         filaments = data.get("filaments", [])
-        profiles = []
+        expected_nozzle = getattr(self, '_expected_kprofile_nozzle', None)
+        has_pending_request = self._pending_kprofile_response is not None
+
+        # Log all incoming responses when we have a pending request (for debugging)
+        if has_pending_request:
+            logger.info(f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}")
+
+        # If we have a pending request, only accept responses with matching nozzle_diameter
+        # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
+        if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
+            # Ignore this broadcast, keep waiting for matching response
+            logger.debug(f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}")
+            return
+
+        # If no pending request, this is just a broadcast - update state silently and return early
+        if not has_pending_request:
+            # Still parse profiles to keep state updated, but don't log
+            profiles = []
+            for f in filaments:
+                if isinstance(f, dict):
+                    try:
+                        cali_idx = f.get("cali_idx", 0)
+                        profiles.append(KProfile(
+                            slot_id=cali_idx,
+                            extruder_id=int(f.get("extruder_id", 0)),
+                            nozzle_id=str(f.get("nozzle_id", "")),
+                            nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
+                            filament_id=str(f.get("filament_id", "")),
+                            name=str(f.get("name", "")),
+                            k_value=str(f.get("k_value", "0.000000")),
+                            n_coef=str(f.get("n_coef", "0.000000")),
+                            ams_id=int(f.get("ams_id", 0)),
+                            tray_id=int(f.get("tray_id", -1)),
+                            setting_id=f.get("setting_id"),
+                        ))
+                    except (ValueError, TypeError):
+                        pass
+            self.state.kprofiles = profiles
+            return
 
 
-        # Log first profile to see what fields the printer returns
-        if filaments and isinstance(filaments[0], dict):
-            logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
-            logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
+        profiles = []
 
 
         for i, f in enumerate(filaments):
         for i, f in enumerate(filaments):
             if isinstance(f, dict):
             if isinstance(f, dict):
@@ -1817,17 +1863,16 @@ class BambuMQTTClient:
         self.state.kprofiles = profiles
         self.state.kprofiles = profiles
         self._kprofile_response_data = profiles
         self._kprofile_response_data = profiles
 
 
-        # Signal that we received the response
+        # Signal that we received the response (only if we were waiting for one)
         # Use thread-safe method since MQTT callbacks run in a different thread
         # Use thread-safe method since MQTT callbacks run in a different thread
         if self._pending_kprofile_response:
         if self._pending_kprofile_response:
+            logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={response_nozzle}")
             if self._loop and self._loop.is_running():
             if self._loop and self._loop.is_running():
                 self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
                 self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
             else:
             else:
                 # Fallback for when loop is not available
                 # Fallback for when loop is not available
                 self._pending_kprofile_response.set()
                 self._pending_kprofile_response.set()
 
 
-        logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
-
     async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
     async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
         """Request K-profiles from the printer with retry logic.
         """Request K-profiles from the printer with retry logic.
 
 
@@ -1858,8 +1903,9 @@ class BambuMQTTClient:
             self._sequence_id += 1
             self._sequence_id += 1
             self._pending_kprofile_response = asyncio.Event()
             self._pending_kprofile_response = asyncio.Event()
             self._kprofile_response_data = None
             self._kprofile_response_data = None
+            self._expected_kprofile_nozzle = nozzle_diameter  # Track which nozzle response we expect
 
 
-            # Send the command
+            # Send the command with nozzle_diameter filter
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_get",
                     "command": "extrusion_cali_get",
@@ -1869,14 +1915,15 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
 
 
-            logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
-            self._client.publish(self.topic_publish, json.dumps(command))
+            logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
+            logger.debug(f"[{self.serial_number}] K-profile request JSON: {json.dumps(command)}")
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
 
-            # Wait for response
+            # Wait for response (response handler already filters by nozzle_diameter)
             try:
             try:
                 await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
                 await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
                 profiles = self._kprofile_response_data or []
                 profiles = self._kprofile_response_data or []
-                logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}")
+                logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}")
                 return profiles
                 return profiles
             except asyncio.TimeoutError:
             except asyncio.TimeoutError:
                 logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
                 logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
@@ -1885,6 +1932,7 @@ class BambuMQTTClient:
                     await asyncio.sleep(0.5)
                     await asyncio.sleep(0.5)
             finally:
             finally:
                 self._pending_kprofile_response = None
                 self._pending_kprofile_response = None
+                self._expected_kprofile_nozzle = None
 
 
         logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
         logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
         return []
         return []
@@ -1912,7 +1960,7 @@ class BambuMQTTClient:
             extruder_id: Extruder ID (0 or 1 for dual nozzle)
             extruder_id: Extruder ID (0 or 1 for dual nozzle)
             setting_id: Existing setting ID for updates, None for new
             setting_id: Existing setting ID for updates, None for new
             slot_id: Calibration index (cali_idx) for the profile
             slot_id: Calibration index (cali_idx) for the profile
-            cali_idx: For H2D edits, the existing slot being edited (enables in-place edit)
+            cali_idx: For edits, the existing slot being edited (enables in-place edit)
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
@@ -1923,95 +1971,111 @@ class BambuMQTTClient:
 
 
         self._sequence_id += 1
         self._sequence_id += 1
 
 
-        # Detect printer type by serial number prefix
-        # X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc.
-        # H2D series (dual nozzle): serial starts with "094"
-        is_dual_nozzle = self.serial_number.startswith("094")
-
-        # For H2D edits, use empty setting_id per OrcaSlicer sniff
-        # For new profiles, generate a setting_id
-        import secrets
+        # Build the filament entry - printer uses cali_idx for profile identification
+        # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
+        # For edits, use the provided cali_idx or slot_id
         if cali_idx is not None:
         if cali_idx is not None:
-            # Edit mode - use empty setting_id per OrcaSlicer sniff
-            setting_id = ""
-        elif not setting_id and slot_id == 0:
-            # New profile - generate setting_id
-            setting_id = f"PFUS{secrets.token_hex(7)}"  # 7 bytes = 14 hex chars
+            effective_cali_idx = cali_idx
+        else:
+            effective_cali_idx = -1 if slot_id == 0 else slot_id
+
+        # Generate a setting_id for new profiles (required by printer)
+        # Format: "PF" + 17 random digits
+        import random
+        if not setting_id and slot_id == 0:
+            setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
+
+        filament_entry = {
+            "ams_id": 0,
+            "cali_idx": effective_cali_idx,
+            "extruder_id": extruder_id,
+            "filament_id": filament_id,
+            "k_value": k_value,
+            "n_coef": "0.000000",
+            "name": name,
+            "nozzle_diameter": nozzle_diameter,
+            "nozzle_id": nozzle_id,
+            "setting_id": setting_id if setting_id else "",
+            "tray_id": -1,
+        }
 
 
-        if is_dual_nozzle:
-            # H2D format - exact OrcaSlicer format (captured via MQTT sniffing)
-            # For edits: include cali_idx (existing slot), slot_id=0, setting_id=""
-            # For new profiles: no cali_idx, slot_id=0, setting_id=generated
-            filament_entry = {
+        command = {
+            "print": {
+                "command": "extrusion_cali_set",
+                "filaments": [filament_entry],
+                "nozzle_diameter": nozzle_diameter,
+                "sequence_id": str(self._sequence_id),
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id==0})")
+        logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
+    def set_kprofiles_batch(
+        self,
+        profiles: list[dict],
+        nozzle_diameter: str = "0.4",
+    ) -> bool:
+        """Set multiple K-profiles in a single command (for dual-nozzle).
+
+        Args:
+            profiles: List of profile dicts, each with:
+                - filament_id, name, k_value, nozzle_id, extruder_id, setting_id (optional), slot_id
+            nozzle_diameter: Common nozzle diameter for all profiles
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set K-profiles batch: not connected")
+            return False
+
+        import random
+        self._sequence_id += 1
+
+        filament_entries = []
+        for p in profiles:
+            slot_id = p.get("slot_id", 0)
+            cali_idx = p.get("cali_idx")
+
+            if cali_idx is not None:
+                effective_cali_idx = cali_idx
+            else:
+                effective_cali_idx = -1 if slot_id == 0 else slot_id
+
+            setting_id = p.get("setting_id")
+            if not setting_id and slot_id == 0:
+                setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
+
+            filament_entries.append({
                 "ams_id": 0,
                 "ams_id": 0,
-                "extruder_id": extruder_id,
-                "filament_id": filament_id,
-                "k_value": k_value,
+                "cali_idx": effective_cali_idx,
+                "extruder_id": p.get("extruder_id", 0),
+                "filament_id": p.get("filament_id", ""),
+                "k_value": p.get("k_value", "0.020000"),
                 "n_coef": "0.000000",
                 "n_coef": "0.000000",
-                "name": name,
+                "name": p.get("name", ""),
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "nozzle_id": nozzle_id,
+                "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                 "setting_id": setting_id if setting_id else "",
                 "setting_id": setting_id if setting_id else "",
-                "slot_id": slot_id,
                 "tray_id": -1,
                 "tray_id": -1,
-            }
-            # For edits, add cali_idx field (position matters - alphabetical order)
-            if cali_idx is not None:
-                # Insert cali_idx in alphabetical position (after ams_id, before extruder_id)
-                # n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff)
-                filament_entry = {
-                    "ams_id": 0,
-                    "cali_idx": cali_idx,
-                    "extruder_id": extruder_id,
-                    "filament_id": filament_id,
-                    "k_value": k_value,
-                    "n_coef": "0.000000",
-                    "name": name,
-                    "nozzle_diameter": nozzle_diameter,
-                    "nozzle_id": nozzle_id,
-                    "setting_id": "",
-                    "slot_id": 0,
-                    "tray_id": -1,
-                }
-            command = {
-                "print": {
-                    "command": "extrusion_cali_set",
-                    "filaments": [filament_entry],
-                    "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
-                }
-            }
-        else:
-            # X1C/P1/A1 format - based on actual X1C profile data:
-            # - n_coef: "1.000000" (NOT 0.000000 like H2D)
-            # - nozzle_id: "" (empty string, NOT the nozzle type)
-            # - tray_id: -1 (NOT 0)
-            filament_entry = {
-                "ams_id": 0,
-                "extruder_id": 0,  # X1C is single nozzle
-                "filament_id": filament_id,
-                "k_value": k_value,
-                "n_coef": "1.000000",  # X1C uses 1.0, not 0.0
-                "name": name,
+            })
+
+        command = {
+            "print": {
+                "command": "extrusion_cali_set",
+                "filaments": filament_entries,
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "nozzle_id": "",  # X1C uses empty string
-                "setting_id": setting_id,
-                "slot_id": slot_id,
-                "tray_id": -1,  # X1C uses -1
-            }
-            command = {
-                "print": {
-                    "command": "extrusion_cali_set",
-                    "filaments": [filament_entry],
-                    "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
-                }
+                "sequence_id": str(self._sequence_id),
             }
             }
+        }
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0}, dual={is_dual_nozzle})")
-        logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
-        # Use QoS 1 for reliable delivery (at least once)
+        logger.info(f"[{self.serial_number}] Setting {len(filament_entries)} K-profiles in batch")
+        logger.info(f"[{self.serial_number}] K-profile SET batch command: {command_json}")
         self._client.publish(self.topic_publish, command_json, qos=1)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
         return True
 
 
@@ -2061,20 +2125,23 @@ class BambuMQTTClient:
                 }
                 }
             }
             }
         else:
         else:
-            # X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields
+            # X1C/P1/A1 format: include all fields like the set command
+            # The delete command structure should match what set uses
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_del",
                     "command": "extrusion_cali_del",
                     "sequence_id": str(self._sequence_id),
                     "sequence_id": str(self._sequence_id),
                     "filament_id": filament_id,
                     "filament_id": filament_id,
                     "cali_idx": cali_idx,
                     "cali_idx": cali_idx,
-                    "setting_id": setting_id,
+                    "setting_id": setting_id if setting_id else "",
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_diameter": nozzle_diameter,
+                    "nozzle_id": nozzle_id,
+                    "extruder_id": extruder_id,
                 }
                 }
             }
             }
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}")
+        logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}")
         logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
         logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
         # Use QoS 1 for reliable delivery (at least once)
         # Use QoS 1 for reliable delivery (at least once)
         self._client.publish(self.topic_publish, command_json, qos=1)
         self._client.publish(self.topic_publish, command_json, qos=1)
@@ -2221,7 +2288,7 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(command))
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info(f"[{self.serial_number}] Set print speed mode to {mode}")
         logger.info(f"[{self.serial_number}] Set print speed mode to {mode}")
         return True
         return True
 
 
@@ -2443,7 +2510,7 @@ class BambuMQTTClient:
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}")
         logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}")
-        self._client.publish(self.topic_publish, command_json)
+        self._client.publish(self.topic_publish, command_json, qos=1)
         logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})")
         logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})")
 
 
         # Track this load request for H2D dual-nozzle disambiguation
         # Track this load request for H2D dual-nozzle disambiguation
@@ -2498,7 +2565,7 @@ class BambuMQTTClient:
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}")
         logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}")
-        self._client.publish(self.topic_publish, command_json)
+        self._client.publish(self.topic_publish, command_json, qos=1)
         logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})")
         logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})")
 
 
         # Clear tracked load request since we're unloading
         # Clear tracked load request since we're unloading
@@ -2532,7 +2599,7 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(command))
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info(f"[{self.serial_number}] AMS control: {action}")
         logger.info(f"[{self.serial_number}] AMS control: {action}")
         return True
         return True
 
 
@@ -2576,7 +2643,7 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(command))
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
         logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
         return True, f"Refreshing AMS {ams_id} tray {tray_id}"
         return True, f"Refreshing AMS {ams_id} tray {tray_id}"
 
 
@@ -2631,7 +2698,7 @@ class BambuMQTTClient:
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
         logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
-        self._client.publish(self.topic_publish, command_json)
+        self._client.publish(self.topic_publish, command_json, qos=1)
         return True
         return True
 
 
     def set_timelapse(self, enable: bool) -> bool:
     def set_timelapse(self, enable: bool) -> bool:
@@ -2661,9 +2728,9 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(timelapse_cmd))
+        self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)
         # Request status update
         # Request status update
-        self._client.publish(self.topic_publish, json.dumps(command))
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}")
         logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}")
         return True
         return True
 
 
@@ -2687,7 +2754,7 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(command))
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         # Request status update
         # Request status update
         pushall = {
         pushall = {
             "pushing": {
             "pushing": {
@@ -2695,6 +2762,6 @@ class BambuMQTTClient:
                 "sequence_id": "0"
                 "sequence_id": "0"
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(pushall))
+        self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
         logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}")
         logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}")
         return True
         return True

+ 322 - 0
docs/bambu_lab_preset_sync_api.md

@@ -0,0 +1,322 @@
+# Bambu Lab Preset Sync API Documentation
+
+This document describes the Bambu Lab cloud API endpoints for syncing slicer presets (filament, print process, and machine profiles) between Bambu Studio and the cloud.
+
+**Captured from:** Bambu Studio v2.4.0.70 with bambu_network_agent v02.04.00.58
+**Date:** 2025-12-08
+
+---
+
+## Authentication
+
+All API requests require authentication via Bearer token.
+
+### Required Headers
+
+```http
+Host: api.bambulab.com
+Authorization: Bearer <access_token>
+User-Agent: bambu_network_agent/02.04.00.58
+X-BBL-Client-Name: BambuStudio
+X-BBL-Client-Type: slicer
+X-BBL-Client-Version: 02.04.00.70
+X-BBL-Device-ID: <uuid>
+X-BBL-Language: en-US
+X-BBL-OS-Type: macos|windows|linux
+X-BBL-OS-Version: <version>
+X-BBL-Agent-Version: 02.04.00.58
+accept: application/json
+```
+
+---
+
+## Endpoints
+
+### 1. Get User Profile
+
+```http
+GET /v1/user-service/my/profile
+```
+
+Returns user account information including UID.
+
+### 2. List All User Presets
+
+```http
+GET /v1/iot-service/api/slicer/setting?version={slicer_version}&public=false
+```
+
+**Parameters:**
+- `version`: Slicer version (e.g., `2.4.0.5`)
+- `public`: Set to `false` for user presets only
+
+**Response:** Returns a list of preset IDs that the user has synced to cloud.
+
+### 3. Get Individual Preset
+
+```http
+GET /v1/iot-service/api/slicer/setting/{preset_id}
+```
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null,
+    "public": false,
+    "version": "1.5.0.20",
+    "type": "filament",
+    "name": "Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle",
+    "update_time": "2025-12-08 01:06:27",
+    "nickname": null,
+    "base_id": "GFSA00",
+    "setting": {
+        "inherits": "Bambu PLA Basic @BBL X1C",
+        "filament_vendor": "\"Devil Design\"",
+        "nozzle_temperature": "225,220",
+        "pressure_advance": "0.03",
+        "updated_time": "1765138658"
+    },
+    "filament_id": null
+}
+```
+
+---
+
+## Preset ID Naming Convention
+
+Preset IDs follow a specific prefix pattern indicating the type:
+
+| Prefix | Type | Description |
+|--------|------|-------------|
+| `PPUS` | Print Process | Print/quality settings (layer height, speeds, infill, etc.) |
+| `PFUS` | Filament | Filament settings (temperatures, flow, pressure advance, etc.) |
+| `PMUS` | Printer/Machine | Machine settings (gcode, bed size, kinematics, etc.) |
+
+The suffix after the prefix is a unique hash identifier.
+
+**Examples:**
+- `PPUS1b03400426f57d` - Print process preset
+- `PFUS169056f3003bb4` - Filament preset
+- `PMUSbc396893c54df0` - Machine/printer preset
+
+---
+
+## Preset Response Schema
+
+### Common Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `message` | string | API response status ("success") |
+| `code` | int/null | Error code if any |
+| `error` | string/null | Error message if any |
+| `public` | boolean | Whether preset is publicly shared |
+| `version` | string | Preset version |
+| `type` | string | Preset type: "filament", "print", or "printer" |
+| `name` | string | Display name of the preset |
+| `update_time` | string | Last update timestamp (ISO format) |
+| `nickname` | string/null | Optional user-defined nickname |
+| `base_id` | string | Reference ID of the parent/base preset |
+| `setting` | object | Key-value pairs of customized settings |
+| `filament_id` | string/null | Bambu filament ID if applicable |
+
+### Setting Object
+
+The `setting` object contains **only the delta/modified values** from the parent preset. Key fields include:
+
+- `inherits`: Name of the parent preset this inherits from
+- `updated_time`: Unix timestamp of last modification
+- Other fields depend on preset type (see below)
+
+---
+
+## Preset Types and Common Settings
+
+### Filament Presets (PFUS)
+
+```json
+{
+    "inherits": "Bambu PLA Basic @BBL X1C",
+    "filament_vendor": "\"Devil Design\"",
+    "filament_cost": "20",
+    "filament_settings_id": "\"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\"",
+    "nozzle_temperature": "225,220",
+    "nozzle_temperature_initial_layer": "225,220",
+    "hot_plate_temp": "60",
+    "cool_plate_temp": "60",
+    "textured_plate_temp": "60",
+    "pressure_advance": "0.03",
+    "enable_pressure_advance": "1",
+    "filament_max_volumetric_speed": "30,29",
+    "activate_air_filtration": "1",
+    "during_print_exhaust_fan_speed": "50",
+    "complete_print_exhaust_fan_speed": "50",
+    "close_fan_the_first_x_layers": "2",
+    "overhang_fan_threshold": "10%",
+    "slow_down_layer_time": "5",
+    "temperature_vitrification": "65",
+    "filament_start_gcode": "...",
+    "filament_end_gcode": "..."
+}
+```
+
+### Print Process Presets (PPUS)
+
+```json
+{
+    "inherits": "0.08mm Extra Fine @BBL H2D",
+    "print_settings_id": "# 0.08mm Extra Fine @BBL H2D",
+    "prime_tower_max_speed": "100",
+    "prime_tower_rib_wall": "0",
+    "prime_tower_width": "20"
+}
+```
+
+### Machine/Printer Presets (PMUS)
+
+```json
+{
+    "inherits": "Bambu Lab H2D 0.4 nozzle",
+    "printer_settings_id": "# Bambu Lab H2D 0.4 nozzle",
+    "bed_custom_model": "/path/to/model.stl",
+    "machine_start_gcode": "...",
+    "machine_end_gcode": "...",
+    "change_filament_gcode": "...",
+    "printer_notes": "...",
+    "support_air_filtration": "1"
+}
+```
+
+---
+
+## Base ID Reference
+
+The `base_id` field references Bambu's internal preset database:
+
+| Prefix | Type |
+|--------|------|
+| `GF` | Generic Filament |
+| `GP` | Generic Print Process |
+| `GM` | Generic Machine |
+
+Examples:
+- `GFSA00` - Generic filament base
+- `GP136` - Generic print process base
+- `GM033` - Generic machine base (H2D)
+
+---
+
+## API Operations (Verified)
+
+### Create Preset
+
+```http
+POST /v1/iot-service/api/slicer/setting
+Content-Type: application/json
+
+{
+    "type": "filament",
+    "name": "My Custom PLA",
+    "version": "2.0.0.0",
+    "base_id": "GFSA00",
+    "setting": {
+        "inherits": "Bambu PLA Basic @BBL X1C",
+        "nozzle_temperature": "210,205",
+        "updated_time": "1733665800"
+    }
+}
+```
+
+**Required fields:**
+- `type`: "filament", "print", or "printer"
+- `name`: Display name
+- `version`: Version string (e.g., "2.0.0.0", "2.3.0.2")
+- `base_id`: Parent preset ID
+- `setting`: Object with modified values including `updated_time` (Unix timestamp)
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null,
+    "setting_id": "PFUSe99f2ff04974b4",
+    "update_time": "2025-12-08 16:31:48"
+}
+```
+
+### Update Preset
+
+**Important:** The Bambu Cloud API does NOT support true updates via PUT/PATCH.
+
+- `PUT /v1/iot-service/api/slicer/setting/{preset_id}` returns **405 Method Not Allowed**
+- `PATCH /v1/iot-service/api/slicer/setting/{preset_id}` returns **500 Cloud database failed**
+
+**Workaround:** To "update" a preset:
+1. GET the existing preset details
+2. Merge your changes
+3. POST to create a new preset (returns new `setting_id`)
+4. DELETE the old preset
+
+This mimics how Bambu Studio handles preset updates.
+
+### Delete Preset
+
+```http
+DELETE /v1/iot-service/api/slicer/setting/{preset_id}
+```
+
+**Response:**
+```json
+{
+    "message": "success",
+    "code": null,
+    "error": null
+}
+```
+
+---
+
+## Related Endpoints
+
+### Slicer Resources
+
+```http
+GET /v1/iot-service/api/slicer/resource?slicer/plugins/cloud={version}
+GET /v1/iot-service/api/slicer/resource?slicer/printer/bbl={version}
+GET /v1/iot-service/api/slicer/resource?policy/privacy={version}
+```
+
+### User Print Status
+
+```http
+GET /v1/iot-service/api/user/print?force=true
+```
+
+### User Tasks
+
+```http
+GET /v1/user-service/my/tasks?limit=5&offset=0&status=0
+```
+
+### MQTT Certificate
+
+```http
+GET /v1/iot-service/api/user/applications/{app_id}/cert?aes256={encrypted_key}
+```
+
+---
+
+## Notes
+
+1. **Delta Storage**: Presets only store modified values from the parent, using the `inherits` field to reference the base preset.
+
+2. **Version Tracking**: The `updated_time` field (Unix timestamp) is used for sync conflict resolution.
+
+3. **Gcode Escaping**: Gcode fields use `\\n` for newlines within JSON strings.
+
+4. **Multi-value Fields**: Some fields like `nozzle_temperature` contain comma-separated values for different conditions.
+
+5. **Authentication**: The access token can be obtained via Bambu Lab OAuth flow or the `/v1/user-service/user/ticket/{code}` endpoint.

+ 76 - 1
frontend/src/api/client.ts

@@ -283,6 +283,39 @@ export interface SlicerSettingsResponse {
   process: SlicerSetting[];
   process: SlicerSetting[];
 }
 }
 
 
+export interface SlicerSettingDetail {
+  message?: string | null;
+  code?: string | null;
+  error?: string | null;
+  public: boolean;
+  version?: string | null;
+  type: string;
+  name: string;
+  update_time?: string | null;
+  nickname?: string | null;
+  base_id?: string | null;
+  setting: Record<string, unknown>;
+  filament_id?: string | null;
+  setting_id?: string | null;
+}
+
+export interface SlicerSettingCreate {
+  type: string;  // 'filament', 'print', or 'printer'
+  name: string;
+  base_id: string;
+  setting: Record<string, unknown>;
+}
+
+export interface SlicerSettingUpdate {
+  name?: string;
+  setting?: Record<string, unknown>;
+}
+
+export interface SlicerSettingDeleteResponse {
+  success: boolean;
+  message: string;
+}
+
 export interface CloudDevice {
 export interface CloudDevice {
   dev_id: string;
   dev_id: string;
   name: string;
   name: string;
@@ -382,6 +415,7 @@ export interface PrintQueueItem {
   archive_name?: string | null;
   archive_name?: string | null;
   archive_thumbnail?: string | null;
   archive_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
+  print_time_seconds?: number | null;  // Estimated print time from archive
 }
 }
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
@@ -456,6 +490,15 @@ export interface KProfilesResponse {
   nozzle_diameter: string;
   nozzle_diameter: string;
 }
 }
 
 
+export interface KProfileNote {
+  setting_id: string;
+  note: string;
+}
+
+export interface KProfileNotesResponse {
+  notes: Record<string, string>;  // setting_id -> note
+}
+
 // Slot Preset Mapping
 // Slot Preset Mapping
 export interface SlotPresetMapping {
 export interface SlotPresetMapping {
   ams_id: number;
   ams_id: number;
@@ -1004,7 +1047,21 @@ export const api = {
   getCloudSettings: (version = '01.09.00.00') =>
   getCloudSettings: (version = '01.09.00.00') =>
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
   getCloudSettingDetail: (settingId: string) =>
   getCloudSettingDetail: (settingId: string) =>
-    request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
+    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
+  createCloudSetting: (data: SlicerSettingCreate) =>
+    request<SlicerSettingDetail>('/cloud/settings', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>
+    request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteCloudSetting: (settingId: string) =>
+    request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {
+      method: 'DELETE',
+    }),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
   getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
 
 
   // Smart Plugs
   // Smart Plugs
@@ -1079,6 +1136,24 @@ export const api = {
       method: 'DELETE',
       method: 'DELETE',
       body: JSON.stringify(profile),
       body: JSON.stringify(profile),
     }),
     }),
+  setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
+      method: 'POST',
+      body: JSON.stringify(profiles),
+    }),
+
+  // K-Profile Notes (stored locally, not on printer)
+  getKProfileNotes: (printerId: number) =>
+    request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
+  setKProfileNote: (printerId: number, settingId: string, note: string) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {
+      method: 'PUT',
+      body: JSON.stringify({ setting_id: settingId, note }),
+    }),
+  deleteKProfileNote: (printerId: number, settingId: string) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {
+      method: 'DELETE',
+    }),
 
 
   // Slot Preset Mappings
   // Slot Preset Mappings
   getSlotPresets: (printerId: number) =>
   getSlotPresets: (printerId: number) =>

+ 15 - 8
frontend/src/components/BatchTagModal.tsx

@@ -31,8 +31,11 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
   const batchTagMutation = useMutation({
   const batchTagMutation = useMutation({
     mutationFn: async () => {
     mutationFn: async () => {
       const tagsArray = Array.from(selectedTags);
       const tagsArray = Array.from(selectedTags);
-      await Promise.all(
-        selectedIds.map(async (id) => {
+      let successCount = 0;
+
+      // Process sequentially to avoid SQLite database locks
+      for (const id of selectedIds) {
+        try {
           const archive = await api.getArchive(id);
           const archive = await api.getArchive(id);
           const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
           const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
 
 
@@ -45,18 +48,22 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
             newTags = currentTags.filter(t => !selectedTags.has(t));
             newTags = currentTags.filter(t => !selectedTags.has(t));
           }
           }
 
 
-          return api.updateArchive(id, { tags: newTags.join(', ') });
-        })
-      );
-      return { count: selectedIds.length, mode, tags: tagsArray };
+          await api.updateArchive(id, { tags: newTags.join(', ') });
+          successCount++;
+        } catch (err) {
+          console.error(`Failed to update archive ${id}:`, err);
+          throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
+        }
+      }
+      return { count: successCount, mode, tags: tagsArray };
     },
     },
     onSuccess: ({ count, mode, tags }) => {
     onSuccess: ({ count, mode, tags }) => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
       showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
       onClose();
       onClose();
     },
     },
-    onError: () => {
-      showToast('Failed to update tags', 'error');
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update tags', 'error');
     },
     },
   });
   });
 
 

+ 243 - 0
frontend/src/components/EditQueueItemModal.tsx

@@ -0,0 +1,243 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface EditQueueItemModalProps {
+  item: PrintQueueItem;
+  onClose: () => void;
+}
+
+export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [printerId, setPrinterId] = useState<number>(item.printer_id);
+
+  // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
+  const isPlaceholderDate = item.scheduled_time &&
+    new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
+
+  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>(
+    item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap'
+  );
+  const [scheduledTime, setScheduledTime] = useState(() => {
+    if (item.scheduled_time && !isPlaceholderDate) {
+      // Convert ISO to local datetime-local format
+      const date = new Date(item.scheduled_time);
+      return date.toISOString().slice(0, 16);
+    }
+    return '';
+  });
+  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
+  const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const updateMutation = useMutation({
+    mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Queue item updated');
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to update queue item', 'error');
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+
+    const data: PrintQueueItemUpdate = {
+      printer_id: printerId,
+      require_previous_success: requirePreviousSuccess,
+      auto_off_after: autoOffAfter,
+    };
+
+    if (scheduleType === 'scheduled' && scheduledTime) {
+      data.scheduled_time = new Date(scheduledTime).toISOString();
+    } else {
+      data.scheduled_time = null;
+    }
+
+    updateMutation.mutate(data);
+  };
+
+  // Get minimum datetime (now + 1 minute)
+  const getMinDateTime = () => {
+    const now = new Date();
+    now.setMinutes(now.getMinutes() + 1);
+    return now.toISOString().slice(0, 16);
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
+        <CardContent className="p-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-2">
+              <Pencil className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Form */}
+          <form onSubmit={handleSubmit} className="p-4 space-y-4">
+            {/* Archive name */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Print Job</label>
+              <p className="text-white font-medium truncate">
+                {item.archive_name || `Archive #${item.archive_id}`}
+              </p>
+            </div>
+
+            {/* Printer selection */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+              {printers?.length === 0 ? (
+                <div className="flex items-center gap-2 text-red-400 text-sm">
+                  <AlertCircle className="w-4 h-4" />
+                  No printers configured
+                </div>
+              ) : (
+                <select
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  value={printerId}
+                  onChange={(e) => setPrinterId(Number(e.target.value))}
+                  required
+                >
+                  {printers?.map((p) => (
+                    <option key={p.id} value={p.id}>{p.name}</option>
+                  ))}
+                </select>
+              )}
+            </div>
+
+            {/* Schedule type */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-2">When to print</label>
+              <div className="flex gap-2">
+                <button
+                  type="button"
+                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                    scheduleType === 'asap'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('asap')}
+                >
+                  <Clock className="w-4 h-4" />
+                  ASAP (when idle)
+                </button>
+                <button
+                  type="button"
+                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                    scheduleType === 'scheduled'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('scheduled')}
+                >
+                  <Calendar className="w-4 h-4" />
+                  Scheduled
+                </button>
+              </div>
+            </div>
+
+            {/* Scheduled time input */}
+            {scheduleType === 'scheduled' && (
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+                <input
+                  type="datetime-local"
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  value={scheduledTime}
+                  onChange={(e) => setScheduledTime(e.target.value)}
+                  min={getMinDateTime()}
+                  required
+                />
+              </div>
+            )}
+
+            {/* Require previous success */}
+            <div className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                id="requirePrevious"
+                checked={requirePreviousSuccess}
+                onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
+                Only start if previous print succeeded
+              </label>
+            </div>
+
+            {/* Auto power off */}
+            <div className="flex items-center gap-2">
+              <input
+                type="checkbox"
+                id="autoOffAfter"
+                checked={autoOffAfter}
+                onChange={(e) => setAutoOffAfter(e.target.checked)}
+                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+              />
+              <label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
+                <Power className="w-3.5 h-3.5" />
+                Power off printer when done
+              </label>
+            </div>
+
+            {/* Help text */}
+            <p className="text-xs text-bambu-gray">
+              {scheduleType === 'asap'
+                ? 'Print will start as soon as the printer is idle.'
+                : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
+            </p>
+
+            {/* Actions */}
+            <div className="flex gap-3 pt-2">
+              <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+                Cancel
+              </Button>
+              <Button
+                type="submit"
+                className="flex-1"
+                disabled={updateMutation.isPending || printers?.length === 0}
+              >
+                {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 647 - 87
frontend/src/components/KProfilesView.tsx

@@ -1,5 +1,5 @@
-import React, { useState, useEffect } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
 import {
 import {
   Gauge,
   Gauge,
   Loader2,
   Loader2,
@@ -11,6 +11,12 @@ import {
   WifiOff,
   WifiOff,
   Trash2,
   Trash2,
   Search,
   Search,
+  Copy,
+  Download,
+  Upload,
+  CheckSquare,
+  Square,
+  StickyNote,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
@@ -21,6 +27,11 @@ import { useToast } from '../contexts/ToastContext';
 interface KProfileCardProps {
 interface KProfileCardProps {
   profile: KProfile;
   profile: KProfile;
   onEdit: () => void;
   onEdit: () => void;
+  onCopy?: () => void;
+  selectionMode?: boolean;
+  isSelected?: boolean;
+  onToggleSelect?: () => void;
+  note?: string;  // Note text to display as preview
 }
 }
 
 
 // Truncate to 3 decimal places (like Bambu Studio) instead of rounding
 // Truncate to 3 decimal places (like Bambu Studio) instead of rounding
@@ -64,33 +75,71 @@ const extractFilamentName = (profileName: string) => {
   return profileName;
   return profileName;
 };
 };
 
 
-function KProfileCard({ profile, onEdit }: KProfileCardProps) {
+function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
   const flowType = getFlowTypeLabel(profile.nozzle_id);
   const flowType = getFlowTypeLabel(profile.nozzle_id);
   const diameter = profile.nozzle_diameter;
   const diameter = profile.nozzle_diameter;
-  const profileName = profile.name || 'Unnamed';
-  // Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
-  const filamentName = extractFilamentName(profile.name || '');
+
+  const handleClick = () => {
+    if (selectionMode && onToggleSelect) {
+      onToggleSelect();
+    } else {
+      onEdit();
+    }
+  };
 
 
   return (
   return (
-    <button
-      onClick={onEdit}
-      className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
-    >
-      <div className="flex items-center gap-2">
-        <span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
-          {truncateK(profile.k_value)}
-        </span>
-        <span className="text-white text-sm truncate flex-1" title={profileName}>
-          {profileName}
-        </span>
-        <span className="text-xs text-bambu-gray whitespace-nowrap">
-          {flowType} {diameter}
-        </span>
-      </div>
-      <div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
-        Filament: {filamentName || profile.filament_id}
-      </div>
-    </button>
+    <div className="flex items-center gap-2">
+      {selectionMode && (
+        <button
+          onClick={onToggleSelect}
+          className="text-bambu-gray hover:text-white transition-colors p-1"
+        >
+          {isSelected ? (
+            <CheckSquare className="w-4 h-4 text-bambu-green" />
+          ) : (
+            <Square className="w-4 h-4" />
+          )}
+        </button>
+      )}
+      <button
+        onClick={handleClick}
+        className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}
+      >
+        <div className="flex items-center gap-2">
+          <span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
+            {truncateK(profile.k_value)}
+          </span>
+          <span className="text-white text-sm truncate flex-1" title={profile.name}>
+            {profile.name || 'Unnamed'}
+          </span>
+          {note && (
+            <span title="Has note">
+              <StickyNote className="w-3 h-3 text-yellow-500" />
+            </span>
+          )}
+          <span className="text-xs text-bambu-gray whitespace-nowrap">
+            {flowType} {diameter}
+          </span>
+        </div>
+        {note && (
+          <div className="text-xs mt-0.5 truncate text-yellow-500/70" title={note}>
+            Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}
+          </div>
+        )}
+      </button>
+      {!selectionMode && onCopy && (
+        <button
+          onClick={(e) => {
+            e.stopPropagation();
+            onCopy();
+          }}
+          className="text-bambu-gray hover:text-white transition-colors p-1"
+          title="Copy profile"
+        >
+          <Copy className="w-4 h-4" />
+        </button>
+      )}
+    </div>
   );
   );
 }
 }
 
 
@@ -100,8 +149,11 @@ interface KProfileModalProps {
   nozzleDiameter: string;
   nozzleDiameter: string;
   existingProfiles?: KProfile[];  // Existing profiles for filament selection
   existingProfiles?: KProfile[];  // Existing profiles for filament selection
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
+  initialNote?: string;  // Initial note value for the profile
+  initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
   onClose: () => void;
   onClose: () => void;
   onSave: () => void;
   onSave: () => void;
+  onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note
 }
 }
 
 
 function KProfileModal({
 function KProfileModal({
@@ -110,11 +162,13 @@ function KProfileModal({
   nozzleDiameter,
   nozzleDiameter,
   existingProfiles = [],
   existingProfiles = [],
   isDualNozzle = false,
   isDualNozzle = false,
+  initialNote = '',
+  initialNoteKey = null,
   onClose,
   onClose,
   onSave,
   onSave,
+  onSaveNote,
 }: KProfileModalProps) {
 }: KProfileModalProps) {
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const queryClient = useQueryClient();
 
 
   const [name, setName] = useState(profile?.name || '');
   const [name, setName] = useState(profile?.name || '');
   const [kValue, setKValue] = useState(
   const [kValue, setKValue] = useState(
@@ -135,6 +189,7 @@ function KProfileModal({
   );
   );
   const [isSyncing, setIsSyncing] = useState(false);
   const [isSyncing, setIsSyncing] = useState(false);
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
+  const [note, setNote] = useState(initialNote);
 
 
   // Extract unique filaments from existing K-profiles on the printer
   // Extract unique filaments from existing K-profiles on the printer
   // These have valid filament_ids that the printer recognizes
   // These have valid filament_ids that the printer recognizes
@@ -162,12 +217,26 @@ function KProfileModal({
     onSuccess: (result) => {
     onSuccess: (result) => {
       console.log('[KProfile] Save success:', result);
       console.log('[KProfile] Save success:', result);
       showToast('K-profile saved');
       showToast('K-profile saved');
+      // Save note if it changed (including clearing it)
+      if (onSaveNote && note !== initialNote) {
+        let profileKey: string;
+        if (note === '' && initialNoteKey) {
+          // Clearing note: use the same key it was stored under
+          profileKey = initialNoteKey;
+        } else if (profile && profile.slot_id > 0) {
+          // Editing: use setting_id if available, or composite key with slot_id
+          profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
+        } else {
+          // New profile: use name as key (will be matched when profile is loaded)
+          profileKey = `name_${name}_${filamentId}`;
+        }
+        onSaveNote(profileKey, note);
+      }
       // Show syncing indicator while printer processes the command
       // Show syncing indicator while printer processes the command
       setIsSyncing(true);
       setIsSyncing(true);
-      // Add delay before refreshing to give printer time to process the save
-      // Bambu printers can be slow to apply K-profile changes
+      // Add delay before closing to give printer time to process the save
+      // onSave will trigger refetch in the parent component
       setTimeout(() => {
       setTimeout(() => {
-        queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
         setIsSyncing(false);
         setIsSyncing(false);
         onSave();
         onSave();
       }, 2500);
       }, 2500);
@@ -189,13 +258,12 @@ function KProfileModal({
       showToast('K-profile deleted');
       showToast('K-profile deleted');
       // Show syncing indicator while printer processes the command
       // Show syncing indicator while printer processes the command
       setIsSyncing(true);
       setIsSyncing(true);
-      // Add delay before refreshing to give printer time to process the delete
-      // Bambu printers can be slow to apply K-profile changes
+      // Add longer delay for delete - printer needs more time to process
+      // before it can return the updated profile list
       setTimeout(() => {
       setTimeout(() => {
-        queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
         setIsSyncing(false);
         setIsSyncing(false);
         onClose();
         onClose();
-      }, 2500);
+      }, 4000);
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
       console.error('[KProfile] Delete error:', error);
       console.error('[KProfile] Delete error:', error);
@@ -249,49 +317,48 @@ function KProfileModal({
       return;
       return;
     }
     }
 
 
-    // For new profiles with multiple extruders: save sequentially
+    // For new profiles with multiple extruders: use batch endpoint
     setIsSyncing(true);
     setIsSyncing(true);
-    setSavingProgress({ current: 0, total: selectedExtruders.length });
+    setSavingProgress({ current: 1, total: selectedExtruders.length });
 
 
-    for (let i = 0; i < selectedExtruders.length; i++) {
-      const extruderId = selectedExtruders[i];
-      const payload = {
-        name: name,
-        k_value: formattedKValue,
-        filament_id: filamentId,
-        nozzle_id: nozzleId,
-        nozzle_diameter: modalDiameter,
-        extruder_id: extruderId,
-        setting_id: undefined,
-        slot_id: 0,
-      };
+    // Build payload for all selected extruders
+    const batchPayload = selectedExtruders.map(extruderId => ({
+      name: name,
+      k_value: formattedKValue,
+      filament_id: filamentId,
+      nozzle_id: nozzleId,
+      nozzle_diameter: modalDiameter,
+      extruder_id: extruderId,
+      setting_id: undefined,
+      slot_id: 0,
+    }));
 
 
-      setSavingProgress({ current: i + 1, total: selectedExtruders.length });
-      console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
+    console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
 
 
-      try {
-        await api.setKProfile(printerId, payload);
-        // Wait between saves to let printer process
-        if (i < selectedExtruders.length - 1) {
-          await new Promise(resolve => setTimeout(resolve, 1500));
-        }
-      } catch (error) {
-        console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
-        showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
-        setIsSyncing(false);
-        setSavingProgress({ current: 0, total: 0 });
-        return;
+    try {
+      await api.setKProfilesBatch(printerId, batchPayload);
+      showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
+      // Save note for new batch profiles
+      if (onSaveNote && note) {
+        const profileKey = `name_${name}_${filamentId}`;
+        onSaveNote(profileKey, note);
       }
       }
+    } catch (error) {
+      console.error('[KProfile] Failed to save batch:', error);
+      showToast('Failed to save K-profiles', 'error');
+      setIsSyncing(false);
+      setSavingProgress({ current: 0, total: 0 });
+      return;
     }
     }
 
 
-    showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
+    setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
     // Wait for final sync before closing
     // Wait for final sync before closing
+    // onSave will trigger refetch in the parent component
     setTimeout(() => {
     setTimeout(() => {
-      queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
       setIsSyncing(false);
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       setSavingProgress({ current: 0, total: 0 });
       onSave();
       onSave();
-    }, 2500);
+    }, 3000);
   };
   };
 
 
   return (
   return (
@@ -425,7 +492,7 @@ function KProfileModal({
                     if (!profile && filamentId && !name) {
                     if (!profile && filamentId && !name) {
                       const selectedFilament = knownFilaments.find(f => f.id === filamentId);
                       const selectedFilament = knownFilaments.find(f => f.id === filamentId);
                       if (selectedFilament) {
                       if (selectedFilament) {
-                        const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
+                        const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
                         setName(`${flowLabel} ${selectedFilament.name}`);
                         setName(`${flowLabel} ${selectedFilament.name}`);
                       }
                       }
                     }
                     }
@@ -502,6 +569,21 @@ function KProfileModal({
               </div>
               </div>
             )}
             )}
 
 
+            {/* Notes */}
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
+              <textarea
+                value={note}
+                onChange={(e) => setNote(e.target.value)}
+                placeholder="Add notes about this profile..."
+                rows={2}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
+              />
+              <p className="text-xs text-bambu-gray mt-1">
+                Notes are saved in Bambusy, not on the printer
+              </p>
+            </div>
+
             <div className="flex gap-2 pt-4">
             <div className="flex gap-2 pt-4">
               {profile && (
               {profile && (
                 <Button
                 <Button
@@ -595,15 +677,52 @@ function KProfileModal({
 
 
 type ExtruderFilter = 'all' | 'left' | 'right';
 type ExtruderFilter = 'all' | 'left' | 'right';
 type FlowTypeFilter = 'all' | 'hf' | 's';
 type FlowTypeFilter = 'all' | 'hf' | 's';
+type SortOption = 'name' | 'k_value' | 'filament';
+
+// localStorage keys
+const STORAGE_KEYS = {
+  NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',
+  SORT_OPTION: 'bambusy_kprofiles_sort',
+};
 
 
 export function KProfilesView() {
 export function KProfilesView() {
+  const { showToast } = useToast();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
-  const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
+  // Load nozzle diameter from localStorage
+  const [nozzleDiameter, setNozzleDiameter] = useState(() => {
+    const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);
+    return saved || '0.4';
+  });
   const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
   const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
   const [showAddModal, setShowAddModal] = useState(false);
   const [showAddModal, setShowAddModal] = useState(false);
+  const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
   const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
   const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
   const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
+  // Load sort option from localStorage
+  const [sortOption, setSortOption] = useState<SortOption>(() => {
+    const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);
+    return (saved as SortOption) || 'name';
+  });
+  // Bulk selection mode
+  // Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders
+  const [selectionMode, setSelectionMode] = useState(false);
+  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
+  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
+  const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
+
+  // Helper to create unique profile key for selection
+  const getProfileKey = (profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`;
+
+  // Save nozzle diameter to localStorage when it changes
+  useEffect(() => {
+    localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);
+  }, [nozzleDiameter]);
+
+  // Save sort option to localStorage when it changes
+  useEffect(() => {
+    localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);
+  }, [sortOption]);
 
 
   // Get available printers
   // Get available printers
   const { data: printers, isLoading: printersLoading } = useQuery({
   const { data: printers, isLoading: printersLoading } = useQuery({
@@ -611,21 +730,47 @@ export function KProfilesView() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
-  // Get K-profiles for selected printer
+  // Get K-profiles for selected printer (filtered by nozzle diameter)
   const {
   const {
     data: kprofiles,
     data: kprofiles,
     isLoading: kprofilesLoading,
     isLoading: kprofilesLoading,
+    isFetching,
     error: kprofilesError,
     error: kprofilesError,
     refetch: refetchProfiles,
     refetch: refetchProfiles,
   } = useQuery({
   } = useQuery({
     queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
     queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
-    queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
+    queryFn: async () => {
+      console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);
+      const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);
+      console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');
+      return result;
+    },
     enabled: !!selectedPrinter,
     enabled: !!selectedPrinter,
     retry: false,
     retry: false,
     staleTime: 0,  // Always consider data stale to ensure fresh fetch
     staleTime: 0,  // Always consider data stale to ensure fresh fetch
+    gcTime: 0,  // Don't cache results
     refetchOnMount: 'always',  // Always refetch when component mounts
     refetchOnMount: 'always',  // Always refetch when component mounts
   });
   });
 
 
+  // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
+  const { data: allProfiles } = useQuery({
+    queryKey: ['kprofiles', selectedPrinter, '0.4'],
+    queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
+    enabled: !!selectedPrinter,
+    staleTime: 60000,  // Cache for 1 minute
+  });
+
+  // Fetch K-profile notes (stored locally)
+  const {
+    data: notesData,
+    refetch: refetchNotes,
+  } = useQuery({
+    queryKey: ['kprofile-notes', selectedPrinter],
+    queryFn: () => api.getKProfileNotes(selectedPrinter!),
+    enabled: !!selectedPrinter,
+    staleTime: 30000,  // Cache for 30 seconds
+  });
+
   // Check if error is due to printer not being connected
   // Check if error is due to printer not being connected
   const isOfflineError = kprofilesError?.message?.includes('not connected');
   const isOfflineError = kprofilesError?.message?.includes('not connected');
 
 
@@ -653,11 +798,12 @@ export function KProfilesView() {
   // Get connected printers for display
   // Get connected printers for display
   const connectedPrinters = printers?.filter((p) => p.is_active) || [];
   const connectedPrinters = printers?.filter((p) => p.is_active) || [];
 
 
-  // Filter profiles based on search query, extruder filter, and flow type
+  // Filter and sort profiles
+  // Note: nozzle diameter filtering is done server-side via MQTT request
   const filteredProfiles = React.useMemo(() => {
   const filteredProfiles = React.useMemo(() => {
     if (!kprofiles?.profiles) return [];
     if (!kprofiles?.profiles) return [];
 
 
-    return kprofiles.profiles.filter((p) => {
+    const filtered = kprofiles.profiles.filter((p) => {
       // Search filter - match name or filament_id (case-insensitive)
       // Search filter - match name or filament_id (case-insensitive)
       const query = searchQuery.toLowerCase();
       const query = searchQuery.toLowerCase();
       const matchesSearch =
       const matchesSearch =
@@ -679,12 +825,239 @@ export function KProfilesView() {
 
 
       return matchesSearch && matchesExtruder && matchesFlowType;
       return matchesSearch && matchesExtruder && matchesFlowType;
     });
     });
-  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
+
+    // Sort profiles
+    return filtered.sort((a, b) => {
+      switch (sortOption) {
+        case 'k_value':
+          return parseFloat(a.k_value) - parseFloat(b.k_value);
+        case 'filament':
+          return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
+        case 'name':
+        default:
+          return a.name.localeCompare(b.name);
+      }
+    });
+  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
 
 
   // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
   // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
 
 
+  // Keyboard shortcuts
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Don't trigger shortcuts when typing in input fields
+      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
+        return;
+      }
+      // Don't trigger when modal is open
+      if (editingProfile || showAddModal || copyingProfile) {
+        return;
+      }
+
+      if (e.key === 'r' || e.key === 'R') {
+        e.preventDefault();
+        refetchProfiles();
+      } else if (e.key === 'n' || e.key === 'N') {
+        e.preventDefault();
+        setShowAddModal(true);
+      } else if (e.key === 'Escape' && selectionMode) {
+        e.preventDefault();
+        setSelectionMode(false);
+        setSelectedProfiles(new Set());
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);
+
+  // Export profiles to JSON file
+  const handleExport = useCallback(() => {
+    if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
+      showToast('No profiles to export', 'error');
+      return;
+    }
+
+    const exportData = {
+      version: 1,
+      exported_at: new Date().toISOString(),
+      printer: selectedPrinterData?.name || 'Unknown',
+      nozzle_diameter: nozzleDiameter,
+      profiles: kprofiles.profiles.map(p => ({
+        name: p.name,
+        k_value: p.k_value,
+        filament_id: p.filament_id,
+        nozzle_id: p.nozzle_id,
+        nozzle_diameter: p.nozzle_diameter,
+        extruder_id: p.extruder_id,
+      })),
+    };
+
+    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+    showToast(`Exported ${kprofiles.profiles.length} profiles`);
+  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
+
+  // Import profiles from JSON file
+  const handleImport = useCallback(() => {
+    const input = document.createElement('input');
+    input.type = 'file';
+    input.accept = '.json';
+    input.onchange = async (e) => {
+      const file = (e.target as HTMLInputElement).files?.[0];
+      if (!file) return;
+
+      try {
+        const text = await file.text();
+        const data = JSON.parse(text);
+
+        if (!data.profiles || !Array.isArray(data.profiles)) {
+          showToast('Invalid file format', 'error');
+          return;
+        }
+
+        // Import profiles one by one
+        let imported = 0;
+        for (const p of data.profiles) {
+          if (!p.name || !p.k_value || !p.filament_id) continue;
+
+          try {
+            await api.setKProfile(selectedPrinter!, {
+              name: p.name,
+              k_value: parseFloat(p.k_value).toFixed(6),
+              filament_id: p.filament_id,
+              nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
+              nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
+              extruder_id: p.extruder_id ?? 0,
+              slot_id: 0, // Always create new
+            });
+            imported++;
+            // Small delay between imports
+            await new Promise(resolve => setTimeout(resolve, 500));
+          } catch (err) {
+            console.error('Failed to import profile:', p.name, err);
+          }
+        }
+
+        showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
+        refetchProfiles();
+      } catch (err) {
+        console.error('Import error:', err);
+        showToast('Failed to parse import file', 'error');
+      }
+    };
+    input.click();
+  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
+
+  // Toggle profile selection using composite key
+  const toggleProfileSelection = useCallback((profileKey: string) => {
+    setSelectedProfiles(prev => {
+      const next = new Set(prev);
+      if (next.has(profileKey)) {
+        next.delete(profileKey);
+      } else {
+        next.add(profileKey);
+      }
+      return next;
+    });
+  }, []);
+
+  // Select all visible profiles
+  const selectAllProfiles = useCallback(() => {
+    setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));
+  }, [filteredProfiles, getProfileKey]);
+
+  // Delete selected profiles
+  const handleBulkDelete = useCallback(() => {
+    if (selectedProfiles.size === 0) return;
+    setShowBulkDeleteConfirm(true);
+  }, [selectedProfiles.size]);
+
+  // Execute the actual bulk delete
+  const executeBulkDelete = useCallback(async () => {
+    const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));
+    setBulkDeleteInProgress(true);
+
+    let deleted = 0;
+    for (const profile of profilesToDelete) {
+      try {
+        await api.deleteKProfile(selectedPrinter!, {
+          slot_id: profile.slot_id,
+          extruder_id: profile.extruder_id,
+          nozzle_id: profile.nozzle_id,
+          nozzle_diameter: profile.nozzle_diameter,
+          filament_id: profile.filament_id,
+          setting_id: profile.setting_id,
+        });
+        deleted++;
+        // Small delay between deletes
+        await new Promise(resolve => setTimeout(resolve, 300));
+      } catch (err) {
+        console.error('Failed to delete profile:', profile.name, err);
+      }
+    }
+
+    showToast(`Deleted ${deleted} profiles`);
+    setBulkDeleteInProgress(false);
+    setShowBulkDeleteConfirm(false);
+    setSelectionMode(false);
+    setSelectedProfiles(new Set());
+    refetchProfiles();
+  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
+
+  // Generate possible keys for a profile (for notes lookup)
+  // Returns array of keys to check: setting_id, slot-based, name-based
+  const getProfileKeys = useCallback((profile: KProfile): string[] => {
+    const keys: string[] = [];
+    if (profile.setting_id) {
+      keys.push(profile.setting_id);
+    }
+    // Slot-based key (for profiles without setting_id)
+    keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);
+    // Name-based key (for newly created profiles)
+    keys.push(`name_${profile.name}_${profile.filament_id}`);
+    return keys;
+  }, []);
+
+  // Save note for a profile
+  const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {
+    if (!selectedPrinter) return;
+    try {
+      await api.setKProfileNote(selectedPrinter, profileKey, noteText);
+      refetchNotes();
+    } catch (err) {
+      console.error('Failed to save note:', err);
+      showToast('Failed to save note', 'error');
+    }
+  }, [selectedPrinter, refetchNotes, showToast]);
+
+  // Get note for a profile (checks all possible keys)
+  // Returns { note, key } so we know which key the note was stored under
+  const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {
+    if (!notesData?.notes) return { note: '', key: null };
+    const keys = getProfileKeys(profile);
+    for (const key of keys) {
+      if (notesData.notes[key]) {
+        return { note: notesData.notes[key], key };
+      }
+    }
+    return { note: '', key: null };
+  }, [notesData, getProfileKeys]);
+
+  // Simple getter for display purposes
+  const getNote = useCallback((profile: KProfile) => {
+    return getNoteWithKey(profile).note;
+  }, [getNoteWithKey]);
+
   if (printersLoading) {
   if (printersLoading) {
     return (
     return (
       <div className="flex justify-center py-12">
       <div className="flex justify-center py-12">
@@ -723,6 +1096,14 @@ export function KProfilesView() {
 
 
   return (
   return (
     <>
     <>
+      {/* Loading overlay when refetching profiles (not initial load) */}
+      {isFetching && !kprofilesLoading && (
+        <div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
+          <Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
+          <p className="text-white font-medium">Loading K-Profiles...</p>
+        </div>
+      )}
+
       {/* Printer & Nozzle Selector */}
       {/* Printer & Nozzle Selector */}
       <div className="flex flex-wrap gap-4 mb-6">
       <div className="flex flex-wrap gap-4 mb-6">
         <div className="flex-1 min-w-48">
         <div className="flex-1 min-w-48">
@@ -758,9 +1139,9 @@ export function KProfilesView() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => refetchProfiles()}
             onClick={() => refetchProfiles()}
-            disabled={kprofilesLoading}
+            disabled={isFetching}
           >
           >
-            <RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
+            <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
             Refresh
             Refresh
           </Button>
           </Button>
           <Button onClick={() => setShowAddModal(true)}>
           <Button onClick={() => setShowAddModal(true)}>
@@ -771,7 +1152,7 @@ export function KProfilesView() {
       </div>
       </div>
 
 
       {/* Search & Filter Row */}
       {/* Search & Filter Row */}
-      <div className="flex flex-wrap gap-4 mb-6">
+      <div className="flex flex-wrap gap-4 mb-4">
         <div className="flex-1 min-w-48 relative">
         <div className="flex-1 min-w-48 relative">
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
           <input
           <input
@@ -806,6 +1187,81 @@ export function KProfilesView() {
             <option value="s">S Only</option>
             <option value="s">S Only</option>
           </select>
           </select>
         </div>
         </div>
+        <div className="w-32">
+          <select
+            value={sortOption}
+            onChange={(e) => setSortOption(e.target.value as SortOption)}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          >
+            <option value="name">Sort: Name</option>
+            <option value="k_value">Sort: K-Value</option>
+            <option value="filament">Sort: Filament</option>
+          </select>
+        </div>
+      </div>
+
+      {/* Toolbar Row */}
+      <div className="flex flex-wrap gap-2 mb-6">
+        <Button
+          variant="secondary"
+          onClick={handleExport}
+          disabled={!kprofiles?.profiles?.length}
+          title="Export profiles to JSON"
+        >
+          <Download className="w-4 h-4" />
+          Export
+        </Button>
+        <Button
+          variant="secondary"
+          onClick={handleImport}
+          title="Import profiles from JSON"
+        >
+          <Upload className="w-4 h-4" />
+          Import
+        </Button>
+        <div className="flex-1" />
+        {selectionMode ? (
+          <>
+            <Button
+              variant="secondary"
+              onClick={selectAllProfiles}
+              title="Select all visible profiles"
+            >
+              <CheckSquare className="w-4 h-4" />
+              Select All
+            </Button>
+            <Button
+              variant="secondary"
+              onClick={handleBulkDelete}
+              disabled={selectedProfiles.size === 0}
+              className="text-red-500 hover:bg-red-500/10"
+              title={`Delete ${selectedProfiles.size} selected profiles`}
+            >
+              <Trash2 className="w-4 h-4" />
+              Delete ({selectedProfiles.size})
+            </Button>
+            <Button
+              variant="secondary"
+              onClick={() => {
+                setSelectionMode(false);
+                setSelectedProfiles(new Set());
+              }}
+            >
+              <X className="w-4 h-4" />
+              Cancel
+            </Button>
+          </>
+        ) : (
+          <Button
+            variant="secondary"
+            onClick={() => setSelectionMode(true)}
+            disabled={!filteredProfiles.length}
+            title="Enter selection mode for bulk delete"
+          >
+            <CheckSquare className="w-4 h-4" />
+            Select
+          </Button>
+        )}
       </div>
       </div>
 
 
       {/* K-Profiles Grid */}
       {/* K-Profiles Grid */}
@@ -839,9 +1295,14 @@ export function KProfilesView() {
                   .filter((p) => p.extruder_id === 1)
                   .filter((p) => p.extruder_id === 1)
                   .map((profile) => (
                   .map((profile) => (
                     <KProfileCard
                     <KProfileCard
-                      key={profile.slot_id}
+                      key={getProfileKey(profile)}
                       profile={profile}
                       profile={profile}
                       onEdit={() => setEditingProfile(profile)}
                       onEdit={() => setEditingProfile(profile)}
+                      onCopy={() => setCopyingProfile(profile)}
+                      selectionMode={selectionMode}
+                      isSelected={selectedProfiles.has(getProfileKey(profile))}
+                      onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
+                      note={getNote(profile)}
                     />
                     />
                   ))}
                   ))}
               </div>
               </div>
@@ -854,9 +1315,14 @@ export function KProfilesView() {
                   .filter((p) => p.extruder_id === 0)
                   .filter((p) => p.extruder_id === 0)
                   .map((profile) => (
                   .map((profile) => (
                     <KProfileCard
                     <KProfileCard
-                      key={profile.slot_id}
+                      key={getProfileKey(profile)}
                       profile={profile}
                       profile={profile}
                       onEdit={() => setEditingProfile(profile)}
                       onEdit={() => setEditingProfile(profile)}
+                      onCopy={() => setCopyingProfile(profile)}
+                      selectionMode={selectionMode}
+                      isSelected={selectedProfiles.has(getProfileKey(profile))}
+                      onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
+                      note={getNote(profile)}
                     />
                     />
                   ))}
                   ))}
               </div>
               </div>
@@ -867,9 +1333,14 @@ export function KProfilesView() {
           <div className="space-y-1">
           <div className="space-y-1">
             {filteredProfiles.map((profile) => (
             {filteredProfiles.map((profile) => (
               <KProfileCard
               <KProfileCard
-                key={profile.slot_id}
+                key={getProfileKey(profile)}
                 profile={profile}
                 profile={profile}
                 onEdit={() => setEditingProfile(profile)}
                 onEdit={() => setEditingProfile(profile)}
+                onCopy={() => setCopyingProfile(profile)}
+                selectionMode={selectionMode}
+                isSelected={selectedProfiles.has(getProfileKey(profile))}
+                onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
+                note={getNote(profile)}
               />
               />
             ))}
             ))}
           </div>
           </div>
@@ -901,29 +1372,118 @@ export function KProfilesView() {
       )}
       )}
 
 
       {/* Edit Modal */}
       {/* Edit Modal */}
-      {editingProfile && selectedPrinter && (
+      {editingProfile && selectedPrinter && (() => {
+        const { note, key } = getNoteWithKey(editingProfile);
+        return (
+          <KProfileModal
+            profile={editingProfile}
+            printerId={selectedPrinter}
+            nozzleDiameter={nozzleDiameter}
+            existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            isDualNozzle={isDualNozzle}
+            initialNote={note}
+            initialNoteKey={key}
+            onSaveNote={handleSaveNote}
+            onClose={() => {
+              console.log('[KProfiles] Edit modal onClose - refetching profiles...');
+              setEditingProfile(null);
+              refetchProfiles();  // Refetch after close (handles delete case)
+            }}
+            onSave={() => {
+              setEditingProfile(null);
+              refetchProfiles();
+            }}
+          />
+        );
+      })()}
+
+      {/* Add Modal */}
+      {showAddModal && selectedPrinter && (
         <KProfileModal
         <KProfileModal
-          profile={editingProfile}
           printerId={selectedPrinter}
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={kprofiles?.profiles}
+          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
-          onClose={() => setEditingProfile(null)}
-          onSave={() => setEditingProfile(null)}
+          onSaveNote={handleSaveNote}
+          onClose={() => {
+            setShowAddModal(false);
+            refetchProfiles();  // Refetch after close
+          }}
+          onSave={() => {
+            setShowAddModal(false);
+            refetchProfiles();
+          }}
         />
         />
       )}
       )}
 
 
-      {/* Add Modal */}
-      {showAddModal && selectedPrinter && (
+      {/* Copy Modal - opens add modal with prefilled values from source profile */}
+      {copyingProfile && selectedPrinter && (
         <KProfileModal
         <KProfileModal
           printerId={selectedPrinter}
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={kprofiles?.profiles}
+          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
-          onClose={() => setShowAddModal(false)}
-          onSave={() => setShowAddModal(false)}
+          onSaveNote={handleSaveNote}
+          // Pass profile data but without slot_id to create a new profile
+          profile={{
+            ...copyingProfile,
+            slot_id: 0,  // Force new profile creation
+            name: `${copyingProfile.name} (Copy)`,  // Indicate it's a copy
+          }}
+          onClose={() => {
+            setCopyingProfile(null);
+            refetchProfiles();
+          }}
+          onSave={() => {
+            setCopyingProfile(null);
+            refetchProfiles();
+          }}
         />
         />
       )}
       )}
+
+      {/* Bulk Delete Confirmation Modal */}
+      {showBulkDeleteConfirm && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+          <Card className="w-full max-w-sm">
+            <CardContent className="p-6">
+              <div className="flex items-center gap-3 mb-4">
+                <div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
+                  <Trash2 className="w-5 h-5 text-red-500" />
+                </div>
+                <div>
+                  <h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
+                  <p className="text-sm text-bambu-gray">This cannot be undone</p>
+                </div>
+              </div>
+              <p className="text-bambu-gray mb-6">
+                Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
+              </p>
+              <div className="flex gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => setShowBulkDeleteConfirm(false)}
+                  disabled={bulkDeleteInProgress}
+                  className="flex-1"
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={executeBulkDelete}
+                  disabled={bulkDeleteInProgress}
+                  className="flex-1 bg-red-500 hover:bg-red-600 text-white"
+                >
+                  {bulkDeleteInProgress ? (
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                  ) : (
+                    <Trash2 className="w-4 h-4" />
+                  )}
+                  Delete
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
     </>
     </>
   );
   );
 }
 }

+ 5 - 0
frontend/src/components/KeyboardShortcutsModal.tsx

@@ -37,6 +37,11 @@ function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => strin
       { keys: ['Esc'], description: 'Clear selection / blur input' },
       { keys: ['Esc'], description: 'Clear selection / blur input' },
       { keys: ['Right-click'], description: 'Context menu on cards' },
       { keys: ['Right-click'], description: 'Context menu on cards' },
     ]},
     ]},
+    { category: 'K-Profiles', items: [
+      { keys: ['R'], description: 'Refresh profiles' },
+      { keys: ['N'], description: 'New profile' },
+      { keys: ['Esc'], description: 'Exit selection mode' },
+    ]},
     { category: 'General', items: [
     { category: 'General', items: [
       { keys: ['?'], description: 'Show this help' },
       { keys: ['?'], description: 'Show this help' },
     ]},
     ]},

+ 5 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -1088,6 +1088,11 @@ export function ArchivesPage() {
       {/* Selection Toolbar */}
       {/* Selection Toolbar */}
       {selectionMode && (
       {selectionMode && (
         <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
         <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
+          <Button variant="secondary" size="sm" onClick={clearSelection}>
+            <X className="w-4 h-4" />
+            Close
+          </Button>
+          <div className="w-px h-6 bg-bambu-dark-tertiary" />
           <span className="text-white font-medium">
           <span className="text-white font-medium">
             {selectedIds.size} selected
             {selectedIds.size} selected
           </span>
           </span>
@@ -1095,10 +1100,6 @@ export function ArchivesPage() {
           <Button variant="secondary" size="sm" onClick={selectAll}>
           <Button variant="secondary" size="sm" onClick={selectAll}>
             Select All
             Select All
           </Button>
           </Button>
-          <Button variant="secondary" size="sm" onClick={clearSelection}>
-            <X className="w-4 h-4" />
-            Clear
-          </Button>
           <div className="w-px h-6 bg-bambu-dark-tertiary" />
           <div className="w-px h-6 bg-bambu-dark-tertiary" />
           <Button
           <Button
             variant="secondary"
             variant="secondary"

File diff ditekan karena terlalu besar
+ 885 - 163
frontend/src/pages/ProfilesPage.tsx


+ 593 - 155
frontend/src/pages/QueuePage.tsx

@@ -1,6 +1,23 @@
-import { useState } from 'react';
+import { useState, useMemo, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+} from '@dnd-kit/core';
+import type { DragEndEvent } from '@dnd-kit/core';
+import {
+  arrayMove,
+  SortableContext,
+  sortableKeyboardCoordinates,
+  useSortable,
+  verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import {
 import {
   Clock,
   Clock,
   Trash2,
   Trash2,
@@ -16,17 +33,32 @@ import {
   ExternalLink,
   ExternalLink,
   Power,
   Power,
   StopCircle,
   StopCircle,
+  Pencil,
+  RefreshCw,
+  Timer,
+  ListOrdered,
+  Layers,
+  ArrowUp,
+  ArrowDown,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrintQueueItem } from '../api/client';
 import type { PrintQueueItem } from '../api/client';
-import { Card } from '../components/Card';
+import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
+import { EditQueueItemModal } from '../components/EditQueueItemModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
+function formatDuration(seconds: number | null | undefined): string {
+  if (!seconds) return '--';
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  if (hours > 0) return `${hours}h ${minutes}m`;
+  return `${minutes}m`;
+}
+
 function formatRelativeTime(dateString: string | null): string {
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
   if (!dateString) return 'ASAP';
-  // Parse ISO string - it's in UTC, convert to local for display
   const date = new Date(dateString);
   const date = new Date(dateString);
   const now = new Date();
   const now = new Date();
   const diff = date.getTime() - now.getTime();
   const diff = date.getTime() - now.getTime();
@@ -41,48 +73,91 @@ function formatRelativeTime(dateString: string | null): string {
 
 
 function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
 function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
   const config = {
   const config = {
-    pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10', label: 'Pending' },
-    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10', label: 'Printing' },
-    completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10', label: 'Completed' },
-    failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10', label: 'Failed' },
-    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10', label: 'Skipped' },
-    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10', label: 'Cancelled' },
+    pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', label: 'Pending' },
+    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
+    completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10 border-green-400/20', label: 'Completed' },
+    failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10 border-red-400/20', label: 'Failed' },
+    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
+    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
   };
   };
 
 
   const { icon: Icon, color, label } = config[status];
   const { icon: Icon, color, label } = config[status];
 
 
   return (
   return (
-    <span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${color}`}>
+    <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
       <Icon className="w-3.5 h-3.5" />
       <Icon className="w-3.5 h-3.5" />
       {label}
       {label}
     </span>
     </span>
   );
   );
 }
 }
 
 
-function QueueItemRow({
+// Sortable queue item for drag and drop
+function SortableQueueItem({
   item,
   item,
+  position,
+  onEdit,
   onCancel,
   onCancel,
   onRemove,
   onRemove,
   onStop,
   onStop,
+  onRequeue,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
-  onCancel: (id: number) => void;
-  onRemove: (id: number) => void;
-  onStop: (id: number) => void;
+  position?: number;
+  onEdit: () => void;
+  onCancel: () => void;
+  onRemove: () => void;
+  onStop: () => void;
+  onRequeue: () => void;
 }) {
 }) {
-  const [showCancelConfirm, setShowCancelConfirm] = useState(false);
-  const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
-  const [showStopConfirm, setShowStopConfirm] = useState(false);
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id: item.id, disabled: item.status !== 'pending' });
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  };
+
+  const isPrinting = item.status === 'printing';
+  const isPending = item.status === 'pending';
+  const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
 
   return (
   return (
-    <>
-      <div className="flex items-center gap-4 p-4 bg-bambu-dark-secondary rounded-lg">
-        {item.status === 'pending' && (
-          <GripVertical className="w-5 h-5 text-bambu-gray cursor-grab" />
+    <div
+      ref={setNodeRef}
+      style={style}
+      className={`
+        group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
+        transition-all duration-200 hover:border-bambu-dark-tertiary/80
+        ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
+        ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
+      `}
+    >
+      <div className="flex items-center gap-4 p-4">
+        {/* Drag handle or position number */}
+        {isPending ? (
+          <div
+            {...attributes}
+            {...listeners}
+            className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors"
+          >
+            <GripVertical className="w-4 h-4 text-bambu-gray" />
+          </div>
+        ) : position !== undefined ? (
+          <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
+            #{position}
+          </div>
+        ) : (
+          <div className="w-8" />
         )}
         )}
 
 
         {/* Thumbnail */}
         {/* Thumbnail */}
-        <div className="w-16 h-16 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
+        <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
           {item.archive_thumbnail ? (
             <img
             <img
               src={api.getArchiveThumbnail(item.archive_id)}
               src={api.getArchiveThumbnail(item.archive_id)}
@@ -91,14 +166,14 @@ function QueueItemRow({
             />
             />
           ) : (
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              <Calendar className="w-6 h-6" />
+              <Layers className="w-6 h-6" />
             </div>
             </div>
           )}
           )}
         </div>
         </div>
 
 
         {/* Info */}
         {/* Info */}
         <div className="flex-1 min-w-0">
         <div className="flex-1 min-w-0">
-          <div className="flex items-center gap-2">
+          <div className="flex items-center gap-2 mb-1">
             <p className="text-white font-medium truncate">
             <p className="text-white font-medium truncate">
               {item.archive_name || `Archive #${item.archive_id}`}
               {item.archive_name || `Archive #${item.archive_id}`}
             </p>
             </p>
@@ -110,119 +185,121 @@ function QueueItemRow({
               <ExternalLink className="w-3.5 h-3.5" />
               <ExternalLink className="w-3.5 h-3.5" />
             </Link>
             </Link>
           </div>
           </div>
-          <div className="flex items-center gap-3 mt-1 text-sm text-bambu-gray">
-            <span className="flex items-center gap-1">
+
+          <div className="flex items-center gap-3 text-sm text-bambu-gray">
+            <span className="flex items-center gap-1.5">
               <Printer className="w-3.5 h-3.5" />
               <Printer className="w-3.5 h-3.5" />
               {item.printer_name || `Printer #${item.printer_id}`}
               {item.printer_name || `Printer #${item.printer_id}`}
             </span>
             </span>
-            <span className="flex items-center gap-1">
-              <Clock className="w-3.5 h-3.5" />
-              {formatRelativeTime(item.scheduled_time)}
-            </span>
+            {item.print_time_seconds && (
+              <span className="flex items-center gap-1.5">
+                <Timer className="w-3.5 h-3.5" />
+                {formatDuration(item.print_time_seconds)}
+              </span>
+            )}
+            {isPending && (
+              <span className="flex items-center gap-1.5">
+                <Clock className="w-3.5 h-3.5" />
+                {formatRelativeTime(item.scheduled_time)}
+              </span>
+            )}
           </div>
           </div>
-          {item.require_previous_success && (
-            <p className="text-xs text-orange-400 mt-1">
-              Requires previous print to succeed
-            </p>
-          )}
-          {item.auto_off_after && (
-            <p className="text-xs text-blue-400 mt-1 flex items-center gap-1">
-              <Power className="w-3 h-3" />
-              Will power off when done
-            </p>
+
+          {/* Options badges */}
+          <div className="flex items-center gap-2 mt-2">
+            {item.require_previous_success && (
+              <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
+                Requires previous success
+              </span>
+            )}
+            {item.auto_off_after && (
+              <span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
+                <Power className="w-3 h-3" />
+                Auto power off
+              </span>
+            )}
+          </div>
+
+          {/* Progress bar for printing items - TODO: integrate with WebSocket */}
+          {isPrinting && (
+            <div className="mt-3">
+              <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
+                <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
+            </div>
           )}
           )}
+
+          {/* Error message */}
           {item.error_message && (
           {item.error_message && (
-            <p className="text-xs text-red-400 mt-1 flex items-center gap-1">
+            <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
               <AlertCircle className="w-3 h-3" />
               <AlertCircle className="w-3 h-3" />
               {item.error_message}
               {item.error_message}
             </p>
             </p>
           )}
           )}
         </div>
         </div>
 
 
-        {/* Status */}
+        {/* Status badge */}
         <StatusBadge status={item.status} />
         <StatusBadge status={item.status} />
 
 
         {/* Actions */}
         {/* Actions */}
-        <div className="flex items-center gap-2">
-          {item.status === 'printing' && (
+        <div className="flex items-center gap-1">
+          {isPrinting && (
             <Button
             <Button
               variant="ghost"
               variant="ghost"
               size="sm"
               size="sm"
-              onClick={() => setShowStopConfirm(true)}
+              onClick={onStop}
               title="Stop Print"
               title="Stop Print"
-              className="text-red-400 hover:text-red-300"
+              className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
             >
             >
               <StopCircle className="w-4 h-4" />
               <StopCircle className="w-4 h-4" />
             </Button>
             </Button>
           )}
           )}
-          {item.status === 'pending' && (
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={() => setShowCancelConfirm(true)}
-              title="Cancel"
-            >
-              <X className="w-4 h-4" />
-            </Button>
+          {isPending && (
+            <>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={onEdit}
+                title="Edit"
+              >
+                <Pencil className="w-4 h-4" />
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={onCancel}
+                title="Cancel"
+                className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
+              >
+                <X className="w-4 h-4" />
+              </Button>
+            </>
           )}
           )}
-          {['completed', 'failed', 'skipped', 'cancelled'].includes(item.status) && (
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={() => setShowRemoveConfirm(true)}
-              title="Remove"
-            >
-              <Trash2 className="w-4 h-4" />
-            </Button>
+          {isHistory && (
+            <>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={onRequeue}
+                title="Re-queue"
+                className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
+              >
+                <RefreshCw className="w-4 h-4" />
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={onRemove}
+                title="Remove"
+              >
+                <Trash2 className="w-4 h-4" />
+              </Button>
+            </>
           )}
           )}
         </div>
         </div>
       </div>
       </div>
-
-      {/* Cancel Confirmation Modal */}
-      {showCancelConfirm && (
-        <ConfirmModal
-          title="Cancel Scheduled Print"
-          message={`Are you sure you want to cancel "${item.archive_name || 'this print'}"? It will be removed from the queue.`}
-          confirmText="Cancel Print"
-          variant="danger"
-          onConfirm={() => {
-            onCancel(item.id);
-            setShowCancelConfirm(false);
-          }}
-          onCancel={() => setShowCancelConfirm(false)}
-        />
-      )}
-
-      {/* Remove Confirmation Modal */}
-      {showRemoveConfirm && (
-        <ConfirmModal
-          title="Remove from History"
-          message={`Are you sure you want to remove "${item.archive_name || 'this item'}" from the queue history?`}
-          confirmText="Remove"
-          variant="danger"
-          onConfirm={() => {
-            onRemove(item.id);
-            setShowRemoveConfirm(false);
-          }}
-          onCancel={() => setShowRemoveConfirm(false)}
-        />
-      )}
-
-      {/* Stop Confirmation Modal */}
-      {showStopConfirm && (
-        <ConfirmModal
-          title="Stop Print"
-          message={`Are you sure you want to stop the current print "${item.archive_name || 'this print'}"? This will cancel the print job on the printer.`}
-          confirmText="Stop Print"
-          variant="danger"
-          onConfirm={() => {
-            onStop(item.id);
-            setShowStopConfirm(false);
-          }}
-          onCancel={() => setShowStopConfirm(false)}
-        />
-      )}
-    </>
+    </div>
   );
   );
 }
 }
 
 
@@ -231,11 +308,55 @@ export function QueuePage() {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
+  const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
+  const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
+  const [confirmAction, setConfirmAction] = useState<{
+    type: 'cancel' | 'remove' | 'stop';
+    item: PrintQueueItem;
+  } | null>(null);
+  const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
+    const saved = localStorage.getItem('queue.historySortBy');
+    return (saved as 'date' | 'name' | 'printer') || 'date';
+  });
+  const [historySortAsc, setHistorySortAsc] = useState(() => {
+    const saved = localStorage.getItem('queue.historySortAsc');
+    return saved !== null ? saved === 'true' : false;
+  });
+  const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
+    const saved = localStorage.getItem('queue.pendingSortBy');
+    return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
+  });
+  const [pendingSortAsc, setPendingSortAsc] = useState(() => {
+    const saved = localStorage.getItem('queue.pendingSortAsc');
+    return saved !== null ? saved === 'true' : true;
+  });
+
+  // Persist sort settings to localStorage
+  useEffect(() => {
+    localStorage.setItem('queue.historySortBy', historySortBy);
+  }, [historySortBy]);
+
+  useEffect(() => {
+    localStorage.setItem('queue.historySortAsc', String(historySortAsc));
+  }, [historySortAsc]);
+
+  useEffect(() => {
+    localStorage.setItem('queue.pendingSortBy', pendingSortBy);
+  }, [pendingSortBy]);
+
+  useEffect(() => {
+    localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
+  }, [pendingSortAsc]);
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+  );
 
 
   const { data: queue, isLoading } = useQuery({
   const { data: queue, isLoading } = useQuery({
     queryKey: ['queue', filterPrinter, filterStatus],
     queryKey: ['queue', filterPrinter, filterStatus],
     queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
     queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
-    refetchInterval: 10000,
+    refetchInterval: 5000,
   });
   });
 
 
   const { data: printers } = useQuery({
   const { data: printers } = useQuery({
@@ -270,23 +391,195 @@ export function QueuePage() {
     onError: () => showToast('Failed to stop print', 'error'),
     onError: () => showToast('Failed to stop print', 'error'),
   });
   });
 
 
-  const pendingItems = queue?.filter(i => i.status === 'pending') || [];
+  const reorderMutation = useMutation({
+    mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+    },
+    onError: () => showToast('Failed to reorder queue', 'error'),
+  });
+
+  const requeueMutation = useMutation({
+    mutationFn: (item: PrintQueueItem) => {
+      // Schedule far in future so it doesn't start immediately
+      const futureDate = new Date();
+      futureDate.setFullYear(futureDate.getFullYear() + 1);
+      return api.addToQueue({
+        printer_id: item.printer_id,
+        archive_id: item.archive_id,
+        scheduled_time: futureDate.toISOString(),
+        require_previous_success: false,
+        auto_off_after: false,
+      });
+    },
+    onSuccess: (newItem) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Added back to queue - please set schedule');
+      // Open edit modal for the new item
+      setEditItem(newItem);
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to re-queue item', 'error'),
+  });
+
+  const clearHistoryMutation = useMutation({
+    mutationFn: async () => {
+      const historyItems = queue?.filter(i =>
+        ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
+      ) || [];
+      for (const item of historyItems) {
+        await api.removeFromQueue(item.id);
+      }
+      return historyItems.length;
+    },
+    onSuccess: (count) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
+    },
+    onError: () => showToast('Failed to clear history', 'error'),
+  });
+
+  const pendingItems = useMemo(() => {
+    const items = queue?.filter(i => i.status === 'pending') || [];
+
+    // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
+    const getScheduledTime = (item: PrintQueueItem): number => {
+      if (!item.scheduled_time) return 0;
+      const time = new Date(item.scheduled_time).getTime();
+      // Placeholder dates (> 6 months out) are treated as ASAP
+      const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
+      return time > sixMonthsFromNow ? 0 : time;
+    };
+
+    return [...items].sort((a, b) => {
+      let cmp: number;
+      if (pendingSortBy === 'name') {
+        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+      } else if (pendingSortBy === 'printer') {
+        cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
+      } else if (pendingSortBy === 'time') {
+        // Sort by scheduled start time (when print will begin)
+        cmp = getScheduledTime(a) - getScheduledTime(b);
+      } else {
+        cmp = a.position - b.position;
+      }
+      return pendingSortAsc ? cmp : -cmp;
+    });
+  }, [queue, pendingSortBy, pendingSortAsc]);
   const activeItems = queue?.filter(i => i.status === 'printing') || [];
   const activeItems = queue?.filter(i => i.status === 'printing') || [];
-  const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+  const historyItems = useMemo(() => {
+    const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
+    return [...items].sort((a, b) => {
+      let cmp: number;
+      if (historySortBy === 'name') {
+        cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
+      } else if (historySortBy === 'printer') {
+        cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
+      } else {
+        // Default: by date - most recent first (desc) is the natural order
+        cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
+      }
+      return historySortAsc ? -cmp : cmp;
+    });
+  }, [queue, historySortBy, historySortAsc]);
+
+  // Calculate total queue time
+  const totalQueueTime = useMemo(() => {
+    return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
+  }, [pendingItems]);
+
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+    if (!over || active.id === over.id) return;
+
+    const oldIndex = pendingItems.findIndex(i => i.id === active.id);
+    const newIndex = pendingItems.findIndex(i => i.id === over.id);
+
+    if (oldIndex !== -1 && newIndex !== -1) {
+      const reordered = arrayMove(pendingItems, oldIndex, newIndex);
+      const updates = reordered.map((item, index) => ({
+        id: item.id,
+        position: index + 1,
+      }));
+      reorderMutation.mutate(updates);
+    }
+  };
 
 
   return (
   return (
     <div className="p-8">
     <div className="p-8">
-      <div className="flex items-center justify-between mb-6">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-8">
         <div>
         <div>
-          <h1 className="text-2xl font-bold text-white">Print Queue</h1>
-          <p className="text-bambu-gray mt-1">Schedule and manage print jobs</p>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <ListOrdered className="w-7 h-7 text-bambu-green" />
+            Print Queue
+          </h1>
+          <p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Summary Cards */}
+      <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
+        <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
+                <Play className="w-5 h-5 text-blue-400" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{activeItems.length}</p>
+                <p className="text-sm text-bambu-gray">Printing</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
+                <Clock className="w-5 h-5 text-yellow-400" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
+                <p className="text-sm text-bambu-gray">Queued</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
+                <Timer className="w-5 h-5 text-bambu-green" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
+                <p className="text-sm text-bambu-gray">Total Queue Time</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
+          <CardContent className="p-4">
+            <div className="flex items-center gap-3">
+              <div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
+                <CheckCircle className="w-5 h-5 text-gray-400" />
+              </div>
+              <div>
+                <p className="text-2xl font-bold text-white">{historyItems.length}</p>
+                <p className="text-sm text-bambu-gray">History</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
       {/* Filters */}
       {/* Filters */}
       <div className="flex items-center gap-4 mb-6">
       <div className="flex items-center gap-4 mb-6">
         <select
         <select
-          className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
           value={filterPrinter || ''}
           value={filterPrinter || ''}
           onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
           onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
         >
         >
@@ -297,7 +590,7 @@ export function QueuePage() {
         </select>
         </select>
 
 
         <select
         <select
-          className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
           value={filterStatus}
           value={filterStatus}
           onChange={(e) => setFilterStatus(e.target.value)}
           onChange={(e) => setFilterStatus(e.target.value)}
         >
         >
@@ -309,35 +602,51 @@ export function QueuePage() {
           <option value="skipped">Skipped</option>
           <option value="skipped">Skipped</option>
           <option value="cancelled">Cancelled</option>
           <option value="cancelled">Cancelled</option>
         </select>
         </select>
+
+        <div className="flex-1" />
+
+        {historyItems.length > 0 && (
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowClearHistoryConfirm(true)}
+          >
+            <Trash2 className="w-4 h-4" />
+            Clear History
+          </Button>
+        )}
       </div>
       </div>
 
 
       {isLoading ? (
       {isLoading ? (
         <div className="text-center py-12 text-bambu-gray">Loading...</div>
         <div className="text-center py-12 text-bambu-gray">Loading...</div>
       ) : queue?.length === 0 ? (
       ) : queue?.length === 0 ? (
-        <Card className="p-12 text-center">
-          <Calendar className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-          <h3 className="text-lg font-medium text-white mb-2">No prints scheduled</h3>
-          <p className="text-bambu-gray">
-            Schedule a print from the Archives page using the "Schedule" option in the context menu.
+        <Card className="p-12 text-center border-dashed">
+          <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
+          <h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
+          <p className="text-bambu-gray max-w-md mx-auto">
+            Schedule a print from the Archives page using the "Schedule" option in the context menu,
+            or drag and drop files to get started.
           </p>
           </p>
         </Card>
         </Card>
       ) : (
       ) : (
-        <div className="space-y-6">
+        <div className="space-y-8">
           {/* Active Prints */}
           {/* Active Prints */}
           {activeItems.length > 0 && (
           {activeItems.length > 0 && (
             <div>
             <div>
-              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
-                <Play className="w-5 h-5 text-blue-400" />
+              <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
+                <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
                 Currently Printing
                 Currently Printing
               </h2>
               </h2>
-              <div className="space-y-2">
+              <div className="space-y-3">
                 {activeItems.map((item) => (
                 {activeItems.map((item) => (
-                  <QueueItemRow
+                  <SortableQueueItem
                     key={item.id}
                     key={item.id}
                     item={item}
                     item={item}
-                    onCancel={(id) => cancelMutation.mutate(id)}
-                    onRemove={(id) => removeMutation.mutate(id)}
-                    onStop={(id) => stopMutation.mutate(id)}
+                    onEdit={() => {}}
+                    onCancel={() => {}}
+                    onRemove={() => {}}
+                    onStop={() => setConfirmAction({ type: 'stop', item })}
+                    onRequeue={() => {}}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -347,39 +656,110 @@ export function QueuePage() {
           {/* Pending Queue */}
           {/* Pending Queue */}
           {pendingItems.length > 0 && (
           {pendingItems.length > 0 && (
             <div>
             <div>
-              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
-                <Clock className="w-5 h-5 text-yellow-400" />
-                Queued ({pendingItems.length})
-              </h2>
-              <div className="space-y-2">
-                {pendingItems.map((item) => (
-                  <QueueItemRow
-                    key={item.id}
-                    item={item}
-                    onCancel={(id) => cancelMutation.mutate(id)}
-                    onRemove={(id) => removeMutation.mutate(id)}
-                    onStop={(id) => stopMutation.mutate(id)}
-                  />
-                ))}
+              <div className="flex items-center justify-between mb-4">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Clock className="w-5 h-5 text-yellow-400" />
+                  Queued
+                  <span className="text-sm font-normal text-bambu-gray">
+                    ({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
+                  </span>
+                  <span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
+                    Drag to reorder (ASAP only)
+                  </span>
+                </h2>
+                <div className="flex items-center gap-2">
+                  <select
+                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    value={pendingSortBy}
+                    onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
+                  >
+                    <option value="position">Sort by Position</option>
+                    <option value="name">Sort by Name</option>
+                    <option value="printer">Sort by Printer</option>
+                    <option value="time">Sort by Schedule</option>
+                  </select>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => setPendingSortAsc(!pendingSortAsc)}
+                    title={pendingSortAsc ? 'Ascending' : 'Descending'}
+                    className="px-2"
+                  >
+                    {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
+                  </Button>
+                </div>
               </div>
               </div>
+              <DndContext
+                sensors={sensors}
+                collisionDetection={closestCenter}
+                onDragEnd={handleDragEnd}
+              >
+                <SortableContext
+                  items={pendingItems.map(i => i.id)}
+                  strategy={verticalListSortingStrategy}
+                >
+                  <div className="space-y-3">
+                    {pendingItems.map((item, index) => (
+                      <SortableQueueItem
+                        key={item.id}
+                        item={item}
+                        position={index + 1}
+                        onEdit={() => setEditItem(item)}
+                        onCancel={() => setConfirmAction({ type: 'cancel', item })}
+                        onRemove={() => {}}
+                        onStop={() => {}}
+                        onRequeue={() => {}}
+                      />
+                    ))}
+                  </div>
+                </SortableContext>
+              </DndContext>
             </div>
             </div>
           )}
           )}
 
 
           {/* History */}
           {/* History */}
           {historyItems.length > 0 && (
           {historyItems.length > 0 && (
             <div>
             <div>
-              <h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
-                <CheckCircle className="w-5 h-5 text-bambu-gray" />
-                History ({historyItems.length})
-              </h2>
-              <div className="space-y-2">
-                {historyItems.slice(0, 10).map((item) => (
-                  <QueueItemRow
+              <div className="flex items-center justify-between mb-4">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <CheckCircle className="w-5 h-5 text-bambu-gray" />
+                  History
+                  <span className="text-sm font-normal text-bambu-gray">
+                    ({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
+                  </span>
+                </h2>
+                <div className="flex items-center gap-2">
+                  <select
+                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    value={historySortBy}
+                    onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
+                  >
+                    <option value="date">Sort by Date</option>
+                    <option value="name">Sort by Name</option>
+                    <option value="printer">Sort by Printer</option>
+                  </select>
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => setHistorySortAsc(!historySortAsc)}
+                    title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
+                    className="px-2"
+                  >
+                    {historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
+                  </Button>
+                </div>
+              </div>
+              <div className="space-y-3">
+                {historyItems.slice(0, 20).map((item, index) => (
+                  <SortableQueueItem
                     key={item.id}
                     key={item.id}
                     item={item}
                     item={item}
-                    onCancel={(id) => cancelMutation.mutate(id)}
-                    onRemove={(id) => removeMutation.mutate(id)}
-                    onStop={(id) => stopMutation.mutate(id)}
+                    position={index + 1}
+                    onEdit={() => {}}
+                    onCancel={() => {}}
+                    onRemove={() => setConfirmAction({ type: 'remove', item })}
+                    onStop={() => {}}
+                    onRequeue={() => requeueMutation.mutate(item)}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -387,6 +767,64 @@ export function QueuePage() {
           )}
           )}
         </div>
         </div>
       )}
       )}
+
+      {/* Edit Modal */}
+      {editItem && (
+        <EditQueueItemModal
+          item={editItem}
+          onClose={() => setEditItem(null)}
+        />
+      )}
+
+      {/* Confirm Action Modal */}
+      {confirmAction && (
+        <ConfirmModal
+          title={
+            confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
+            confirmAction.type === 'stop' ? 'Stop Print' :
+            'Remove from History'
+          }
+          message={
+            confirmAction.type === 'cancel'
+              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
+              : confirmAction.type === 'stop'
+              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
+              : `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
+          }
+          confirmText={
+            confirmAction.type === 'cancel' ? 'Cancel Print' :
+            confirmAction.type === 'stop' ? 'Stop Print' :
+            'Remove'
+          }
+          variant="danger"
+          onConfirm={() => {
+            if (confirmAction.type === 'cancel') {
+              cancelMutation.mutate(confirmAction.item.id);
+            } else if (confirmAction.type === 'stop') {
+              stopMutation.mutate(confirmAction.item.id);
+            } else {
+              removeMutation.mutate(confirmAction.item.id);
+            }
+            setConfirmAction(null);
+          }}
+          onCancel={() => setConfirmAction(null)}
+        />
+      )}
+
+      {/* Clear History Confirm Modal */}
+      {showClearHistoryConfirm && (
+        <ConfirmModal
+          title="Clear History"
+          message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
+          confirmText="Clear History"
+          variant="danger"
+          onConfirm={() => {
+            clearHistoryMutation.mutate();
+            setShowClearHistoryConfirm(false);
+          }}
+          onCancel={() => setShowClearHistoryConfirm(false)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-BPFlIKJb.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CUSzovgk.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CZD79C6y.js


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-DL3S7zom.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CUSzovgk.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DL3S7zom.css">
+    <script type="module" crossorigin src="/assets/index-CZD79C6y.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BPFlIKJb.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini