Files
bambuddy/frontend/src/components/ConfirmModal.tsx
T
maziggy 2cf6f29503 fix(vp): Send All enqueues one item per plate; archive delete cascades to queue
VP queue-mode multi-plate Send All
  ==========================================

  BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
  3MF containing every plate (one FTP STOR, single filename) — slice_info.config
  inside the file lists N <plate> blocks with their own index metadata and
  their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
  called _extract_plate_id which returned only the FIRST plate index, and
  _add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
  silently dropped on the floor. From the user's perspective: Send All of a
  3-plate project produced 1 queue item, indistinguishable from a regular
  single-plate Send, with no log line to explain the discrepancy.

  The wire was confirmed against the live H2D-1 Proxy VP: the same file
  ships whether the user clicked Send or Send All; the only intent signal
  is the count of <plate> blocks inside slice_info.config.

  Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
  (-> list[int]). The list contains every <plate> block's index in order;
  falls back to [1] when slice_info.config is missing / unparseable so the
  single-plate case is preserved. _add_to_print_queue now loops over the
  list and creates one PrintQueueItem per plate, with:

    - plate-specific position = MAX(position) + iteration_number, so the
      items inherit consecutive positions and the slicer's plate order
      becomes the queue execution order.
    - per-plate required_filament_types / filament_overrides via
      extract_filament_requirements(file_path, plate_id) — the plate-aware
      filter shipped with #1697 — so the scheduler's per-printer "Any X"
      matching dispatches each plate onto a printer with the right
      colours loaded for THAT plate, not for plate 1's filament set.
    - shared archive_id across all plates (one upload = one archive row).
    - the VP's auto_dispatch + manual_start posture inherited unchanged.

  Net behaviour: single-plate Send hits the loop once → exactly today's
  result (one queue item, plate_id from the slicer, one archive). Multi-
  plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
  consecutive positions, all referencing the same backing archive.

  Archive delete cascades to queue rows
  =============================================

  Previously the soft-delete path (the default the trash-can button uses)
  called _cancel_pending_queue_items which only flipped queue rows with
  status='pending' to status='cancelled' while leaving every other status
  alone AND leaving every row in the DB. The Send All multi-plate work
  above made this much more visible: deleting an archive backed by N
  queue items now had to clean up N rows, and what users saw instead was
  N "cancelled" rows lingering in the queue history.

  Backend:
    - Replaced _cancel_pending_queue_items with _delete_related_queue_items
      (db, archive_id) -> int. DELETEs every queue row where
      archive_id = X regardless of status. Matches what the hard-delete
      path already did via the ON DELETE CASCADE FK on
      print_queue.archive_id — both paths now produce the same end state.
    - Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
      untouched; Quick Stats / accuracy bands are preserved across both
      delete paths.
    - 409 guard on archives.py::delete_archive when any related queue
      item is currently status='printing'. Both soft and hard delete are
      gated; deleting the archive while a print is live would strip the
      dispatcher's metadata trail (filament / plate / ams_mapping) out
      from under the running print.
    - New GET /archives/{id}/delete-impact endpoint returns
      {related_queue_items: N, currently_printing: M}. Cheap, single
      endpoint, deliberately NOT folded into the archive list response
      so the much larger list endpoint isn't forced to run the same
      query per row.

  Frontend:
    - ArchivesPage delete-confirm modal queries the new endpoint when the
      modal opens (useQuery with enabled: showDeleteConfirm) and renders
      an amber "N queue items linked to this archive will also be removed"
      line when total > 0 AND printing = 0, OR a red "Cannot delete —
      M queue items are currently printing" line when printing > 0
      (confirm button disabled in that case so the user can't bonk the
      409 on submit).
    - ConfirmModal gained an optional confirmDisabled?: boolean prop —
      isLoading was the only disable knob before; this adds the external-
      precondition path.
    - 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
      translated across all 11 locales per feedback_translate_dont_fallback —
      no English fallbacks.

  No DB migration — the CASCADE FK was already in place; only the helper's
  semantics changed.
2026-06-13 15:58:57 +02:00

130 lines
4.3 KiB
TypeScript

import { useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
interface ConfirmModalProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';
cardClassName?: string;
// Tailwind z-index utility applied to the fixed overlay. Defaults to
// ``z-50``. Use a higher value (e.g. ``z-[110]``) when this confirm
// dialog is rendered from inside another modal that uses ``z-[100]`` —
// without it the confirm dialog sits behind its parent (#1336 follow-up).
overlayZIndex?: string;
variant?: 'danger' | 'warning' | 'default';
isLoading?: boolean;
loadingText?: string;
// Disable the confirm button without a loading spinner. Used when an
// external precondition forbids the action (e.g. #1734 — a related queue
// item is mid-print, so the archive delete must be blocked at the UI
// layer too even though the backend will 409 anyway).
confirmDisabled?: boolean;
// Optional extra content rendered between the message and the buttons —
// used for opt-in checkboxes (e.g. the "Also remove from statistics"
// toggle in the archive delete confirmation, #1343).
children?: ReactNode;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({
title,
message,
confirmText,
cancelText,
cancelVariant,
cardClassName,
overlayZIndex,
variant = 'default',
isLoading = false,
loadingText,
confirmDisabled = false,
children,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const { t } = useTranslation();
const resolvedConfirmText = confirmText ?? t('common.confirm');
const resolvedCancelText = cancelText ?? t('common.cancel');
const resolvedLoadingText = loadingText ?? t('common.loading');
// Close on Escape key (but not while loading)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isLoading) onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCancel, isLoading]);
const variantStyles = {
danger: {
icon: 'text-red-400',
button: 'bg-red-500 hover:bg-red-600',
},
warning: {
icon: 'text-yellow-400',
button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
},
default: {
icon: 'text-bambu-green',
button: 'bg-bambu-green hover:bg-bambu-green-dark',
},
};
const styles = variantStyles[variant];
return (
<div
className={`fixed inset-0 bg-black/50 flex items-center justify-center p-4 ${overlayZIndex ?? 'z-50'}`}
onClick={isLoading ? undefined : onCancel}
>
<Card
className={`w-full max-w-md ${cardClassName ?? ''}`}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
{children && <div className="mt-4">{children}</div>}
</div>
</div>
<div className="flex gap-3 mt-6">
<Button
variant={cancelVariant ?? 'secondary'}
onClick={onCancel}
className="flex-1"
disabled={isLoading}
>
{resolvedCancelText}
</Button>
<Button
onClick={onConfirm}
className={`flex-1 ${styles.button}`}
disabled={isLoading || confirmDisabled}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{resolvedLoadingText}
</>
) : (
resolvedConfirmText
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}