mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Add bulk printer actions toolbar (#825)
Select multiple printer cards and apply bulk actions (stop, pause, resume, clear notifications, clear bed) from a floating toolbar. Selection shortcuts: Select All, Select by State (printing/paused/ finished/idle/error/offline), Select by Location. Action buttons are smart-enabled based on selected printers' current states. Confirmation modals for destructive actions. The status summary bar now shows all printer states.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -385,4 +385,95 @@ describe('PrintersPage', () => {
|
||||
expect(screen.queryByText('01.01.03.00')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk selection', () => {
|
||||
it('shows select button in toolbar', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The Select button should be in the toolbar (title attribute)
|
||||
const selectButton = screen.getByTitle('Select');
|
||||
expect(selectButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selection toolbar after clicking select button', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the Select button to enter selection mode
|
||||
fireEvent.click(screen.getByTitle('Select'));
|
||||
|
||||
// The floating toolbar should appear with Select All
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows selection count when printers are selected', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enter selection mode
|
||||
fireEvent.click(screen.getByTitle('Select'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select All')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Select All to select both printers
|
||||
fireEvent.click(screen.getByText('Select All'));
|
||||
|
||||
// Should show "2 selected"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows select by state dropdown', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enter selection mode
|
||||
fireEvent.click(screen.getByTitle('Select'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select by State')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('exits selection mode on close button', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enter selection mode
|
||||
fireEvent.click(screen.getByTitle('Select'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select All')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the Select button again to exit (it toggles)
|
||||
fireEvent.click(screen.getByTitle('Select'));
|
||||
|
||||
// Floating toolbar should disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Select All')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
X,
|
||||
Square,
|
||||
Pause,
|
||||
Play,
|
||||
ChevronDown,
|
||||
BellOff,
|
||||
Eraser,
|
||||
} from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { filterKnownHMSErrors } from './HMSErrorModal';
|
||||
import type { Printer, HMSError } from '../api/client';
|
||||
|
||||
export type BulkAction = 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS';
|
||||
export type PrinterState = 'printing' | 'paused' | 'finished' | 'idle' | 'error' | 'offline';
|
||||
|
||||
interface PrinterStatus {
|
||||
connected: boolean;
|
||||
state: string | null;
|
||||
hms_errors?: HMSError[];
|
||||
plate_cleared?: boolean;
|
||||
}
|
||||
|
||||
interface BulkPrinterToolbarProps {
|
||||
selectedIds: Set<number>;
|
||||
printers: Printer[];
|
||||
onClose: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectByLocation: (location: string) => void;
|
||||
onSelectByState: (state: PrinterState) => void;
|
||||
onAction: (action: BulkAction) => void;
|
||||
actionPending: boolean;
|
||||
}
|
||||
|
||||
const STATE_OPTIONS: { key: PrinterState; dot: string }[] = [
|
||||
{ key: 'printing', dot: 'bg-bambu-green' },
|
||||
{ key: 'paused', dot: 'bg-status-warning' },
|
||||
{ key: 'finished', dot: 'bg-blue-400' },
|
||||
{ key: 'idle', dot: 'bg-bambu-green' },
|
||||
{ key: 'error', dot: 'bg-status-error' },
|
||||
{ key: 'offline', dot: 'bg-gray-400' },
|
||||
];
|
||||
|
||||
export function BulkPrinterToolbar({
|
||||
selectedIds,
|
||||
printers,
|
||||
onClose,
|
||||
onSelectAll,
|
||||
onSelectByLocation,
|
||||
onSelectByState,
|
||||
onAction,
|
||||
actionPending,
|
||||
}: BulkPrinterToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [showLocationDropdown, setShowLocationDropdown] = useState(false);
|
||||
const [showStateDropdown, setShowStateDropdown] = useState(false);
|
||||
|
||||
// Read cached statuses for selected printers
|
||||
const selectedStatuses = Array.from(selectedIds).map(id => ({
|
||||
id,
|
||||
status: queryClient.getQueryData<PrinterStatus>(['printerStatus', id]),
|
||||
}));
|
||||
|
||||
// Smart enablement: check if any selected printer is in the right state
|
||||
const anyRunning = selectedStatuses.some(
|
||||
({ status }) => status?.connected && status.state === 'RUNNING',
|
||||
);
|
||||
const anyPaused = selectedStatuses.some(
|
||||
({ status }) => status?.connected && status.state === 'PAUSE',
|
||||
);
|
||||
const anyStoppable = anyRunning || anyPaused;
|
||||
const anyNeedsClearPlate = selectedStatuses.some(
|
||||
({ status }) => status?.connected && (status.state === 'FINISH' || status.state === 'FAILED') && !status.plate_cleared,
|
||||
);
|
||||
const anyWithHMS = selectedStatuses.some(({ status }) => {
|
||||
if (!status?.connected || !status.hms_errors) return false;
|
||||
return filterKnownHMSErrors(status.hms_errors).length > 0;
|
||||
});
|
||||
|
||||
const canControl = hasPermission('printers:control');
|
||||
const canClearPlate = hasPermission('printers:clear_plate');
|
||||
|
||||
// Unique locations from all printers (not just selected)
|
||||
const locations = [...new Set(printers.map(p => p.location).filter((l): l is string => !!l))].sort();
|
||||
|
||||
// Count printers per state for the state dropdown
|
||||
const stateCounts: Record<PrinterState, number> = { printing: 0, paused: 0, finished: 0, idle: 0, error: 0, offline: 0 };
|
||||
printers.forEach(p => {
|
||||
const status = queryClient.getQueryData<PrinterStatus>(['printerStatus', p.id]);
|
||||
if (!status || !status.connected) { stateCounts.offline++; return; }
|
||||
if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) stateCounts.error++;
|
||||
switch (status.state) {
|
||||
case 'RUNNING': stateCounts.printing++; break;
|
||||
case 'PAUSE': stateCounts.paused++; break;
|
||||
case 'FINISH': stateCounts.finished++; break;
|
||||
case 'FAILED': stateCounts.error++; break;
|
||||
default: stateCounts.idle++; break;
|
||||
}
|
||||
});
|
||||
|
||||
const stateLabels: Record<PrinterState, string> = {
|
||||
printing: t('printers.status.printing'),
|
||||
paused: t('printers.status.paused', 'Paused'),
|
||||
finished: t('printers.status.finished', 'Finished'),
|
||||
idle: t('printers.status.available'),
|
||||
error: t('printers.status.problem'),
|
||||
offline: t('printers.status.offline'),
|
||||
};
|
||||
|
||||
return (
|
||||
<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-3 flex-wrap">
|
||||
{/* Close */}
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-bambu-dark-tertiary" />
|
||||
|
||||
{/* Selection count */}
|
||||
<span className="text-white font-medium text-sm">
|
||||
{t('printers.bulk.selected', { count: selectedIds.size })}
|
||||
</span>
|
||||
|
||||
<div className="w-px h-6 bg-bambu-dark-tertiary" />
|
||||
|
||||
{/* Select All */}
|
||||
<Button variant="secondary" size="sm" onClick={onSelectAll}>
|
||||
{t('printers.bulk.selectAll')}
|
||||
</Button>
|
||||
|
||||
{/* Select by State */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setShowStateDropdown(!showStateDropdown); setShowLocationDropdown(false); }}
|
||||
>
|
||||
{t('printers.bulk.selectByState')}
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showStateDropdown ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
{showStateDropdown && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowStateDropdown(false)}
|
||||
/>
|
||||
<div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
|
||||
{STATE_OPTIONS.filter(({ key }) => stateCounts[key] > 0).map(({ key, dot }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
onSelectByState(key);
|
||||
setShowStateDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${dot}`} />
|
||||
{stateLabels[key]}
|
||||
<span className="ml-auto text-bambu-gray text-xs">{stateCounts[key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Select by Location */}
|
||||
{locations.length > 0 && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setShowLocationDropdown(!showLocationDropdown); setShowStateDropdown(false); }}
|
||||
>
|
||||
{t('printers.bulk.selectByLocation')}
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showLocationDropdown ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
{showLocationDropdown && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowLocationDropdown(false)}
|
||||
/>
|
||||
<div className="absolute bottom-full mb-2 left-0 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
|
||||
{locations.map(location => (
|
||||
<button
|
||||
key={location}
|
||||
onClick={() => {
|
||||
onSelectByLocation(location);
|
||||
setShowLocationDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
|
||||
>
|
||||
{location}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-bambu-dark-tertiary" />
|
||||
|
||||
{/* Action buttons */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={() => onAction('stop')}
|
||||
disabled={actionPending || !canControl || !anyStoppable}
|
||||
title={!canControl ? t('printers.permission.noControl') : !anyStoppable ? t('printers.bulk.noneApplicable') : undefined}
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{t('printers.bulk.actions.stop')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAction('pause')}
|
||||
disabled={actionPending || !canControl || !anyRunning}
|
||||
title={!canControl ? t('printers.permission.noControl') : !anyRunning ? t('printers.bulk.noneApplicable') : undefined}
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
{t('printers.bulk.actions.pause')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAction('resume')}
|
||||
disabled={actionPending || !canControl || !anyPaused}
|
||||
title={!canControl ? t('printers.permission.noControl') : !anyPaused ? t('printers.bulk.noneApplicable') : undefined}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{t('printers.bulk.actions.resume')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAction('clearHMS')}
|
||||
disabled={actionPending || !canControl || !anyWithHMS}
|
||||
title={!canControl ? t('printers.permission.noControl') : !anyWithHMS ? t('printers.bulk.noneApplicable') : undefined}
|
||||
>
|
||||
<BellOff className="w-3.5 h-3.5" />
|
||||
{t('printers.bulk.actions.clearHMS')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAction('clearPlate')}
|
||||
disabled={actionPending || !canClearPlate || !anyNeedsClearPlate}
|
||||
title={!canClearPlate ? t('printers.permission.noControl') : !anyNeedsClearPlate ? t('printers.bulk.noneApplicable') : undefined}
|
||||
>
|
||||
<Eraser className="w-3.5 h-3.5" />
|
||||
{t('printers.bulk.actions.clearPlate')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: 'WARNUNG: "{{name}}" druckt gerade! Möchten Sie die Stromversorgung wirklich AUSSCHALTEN? Dies unterbricht den Druck und kann den Drucker beschädigen.',
|
||||
powerOffButton: 'Ausschalten',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: 'Auswählen',
|
||||
selectAll: 'Alle auswählen',
|
||||
selectByLocation: 'Nach Standort auswählen',
|
||||
selected: '{{count}} ausgewählt',
|
||||
actions: {
|
||||
stop: 'Stoppen',
|
||||
pause: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
clearPlate: 'Druckbett leeren',
|
||||
clearHMS: 'Benachrichtigungen löschen',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: '{{count}} Drucke stoppen',
|
||||
stopMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) abbrechen. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
stopButton: 'Alle stoppen',
|
||||
pauseTitle: '{{count}} Drucke pausieren',
|
||||
pauseMessage: 'Dies wird aktive Drucke auf {{count}} Drucker(n) pausieren.',
|
||||
pauseButton: 'Alle pausieren',
|
||||
clearPlateTitle: '{{count}} Druckbetten leeren',
|
||||
clearPlateMessage: 'Dies wird das Druckbett auf {{count}} Drucker(n) leeren und kann wartende Aufträge starten.',
|
||||
clearPlateButton: 'Alle leeren',
|
||||
},
|
||||
success: '{{action}} auf {{count}} Drucker(n) abgeschlossen',
|
||||
partial: '{{succeeded}} erfolgreich, {{failed}} fehlgeschlagen',
|
||||
noneApplicable: 'Keine ausgewählten Drucker sind im richtigen Zustand für diese Aktion',
|
||||
selectByState: 'Nach Status auswählen',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'Drucker entdecken',
|
||||
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: 'WARNING: "{{name}}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.',
|
||||
powerOffButton: 'Power Off',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: 'Select',
|
||||
selectAll: 'Select All',
|
||||
selectByLocation: 'Select by Location',
|
||||
selected: '{{count}} selected',
|
||||
actions: {
|
||||
stop: 'Stop',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
clearPlate: 'Clear Bed',
|
||||
clearHMS: 'Clear Notifications',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: 'Stop {{count}} Prints',
|
||||
stopMessage: 'This will cancel active prints on {{count}} printer(s). This action cannot be undone.',
|
||||
stopButton: 'Stop All',
|
||||
pauseTitle: 'Pause {{count}} Prints',
|
||||
pauseMessage: 'This will pause active prints on {{count}} printer(s).',
|
||||
pauseButton: 'Pause All',
|
||||
clearPlateTitle: 'Clear {{count}} Print Beds',
|
||||
clearPlateMessage: 'This will clear the print bed on {{count}} printer(s) and may trigger queued jobs.',
|
||||
clearPlateButton: 'Clear All',
|
||||
},
|
||||
success: '{{action}} completed on {{count}} printer(s)',
|
||||
partial: '{{succeeded}} succeeded, {{failed}} failed',
|
||||
noneApplicable: 'No selected printers are in the right state for this action',
|
||||
selectByState: 'Select by State',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'Discover Printers',
|
||||
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: 'ATTENTION : "{{name}}" imprime ! L\'éteindre maintenant peut endommager l\'imprimante.',
|
||||
powerOffButton: 'Éteindre',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: 'Sélectionner',
|
||||
selectAll: 'Tout sélectionner',
|
||||
selectByLocation: 'Sélectionner par emplacement',
|
||||
selected: '{{count}} sélectionné(s)',
|
||||
actions: {
|
||||
stop: 'Arrêter',
|
||||
pause: 'Pause',
|
||||
resume: 'Reprendre',
|
||||
clearPlate: 'Vider le plateau',
|
||||
clearHMS: 'Effacer les notifications',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: 'Arrêter {{count}} impressions',
|
||||
stopMessage: 'Cela annulera les impressions actives sur {{count}} imprimante(s). Cette action est irréversible.',
|
||||
stopButton: 'Tout arrêter',
|
||||
pauseTitle: 'Mettre en pause {{count}} impressions',
|
||||
pauseMessage: 'Cela mettra en pause les impressions actives sur {{count}} imprimante(s).',
|
||||
pauseButton: 'Tout mettre en pause',
|
||||
clearPlateTitle: 'Vider {{count}} plateaux',
|
||||
clearPlateMessage: 'Cela videra le plateau sur {{count}} imprimante(s) et pourrait lancer les travaux en file d\'attente.',
|
||||
clearPlateButton: 'Tout vider',
|
||||
},
|
||||
success: '{{action}} terminé sur {{count}} imprimante(s)',
|
||||
partial: '{{succeeded}} réussi(s), {{failed}} échoué(s)',
|
||||
noneApplicable: 'Aucune imprimante sélectionnée n\'est dans le bon état pour cette action',
|
||||
selectByState: 'Sélectionner par état',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'Découvrir les imprimantes',
|
||||
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: 'AVVISO: "{{name}}" sta stampando! Sei sicuro di spegnere? Questo interromperà la stampa e potrebbe danneggiare la stampante.',
|
||||
powerOffButton: 'Spegni',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: 'Seleziona',
|
||||
selectAll: 'Seleziona tutto',
|
||||
selectByLocation: 'Seleziona per posizione',
|
||||
selected: '{{count}} selezionato/i',
|
||||
actions: {
|
||||
stop: 'Ferma',
|
||||
pause: 'Pausa',
|
||||
resume: 'Riprendi',
|
||||
clearPlate: 'Svuota piano',
|
||||
clearHMS: 'Cancella notifiche',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: 'Ferma {{count}} stampe',
|
||||
stopMessage: 'Questo annullerà le stampe attive su {{count}} stampante/i. Questa azione non può essere annullata.',
|
||||
stopButton: 'Ferma tutte',
|
||||
pauseTitle: 'Pausa {{count}} stampe',
|
||||
pauseMessage: 'Questo metterà in pausa le stampe attive su {{count}} stampante/i.',
|
||||
pauseButton: 'Pausa tutte',
|
||||
clearPlateTitle: 'Svuota {{count}} piani di stampa',
|
||||
clearPlateMessage: 'Questo svuoterà il piano di stampa su {{count}} stampante/i e potrebbe avviare i lavori in coda.',
|
||||
clearPlateButton: 'Svuota tutti',
|
||||
},
|
||||
success: '{{action}} completato su {{count}} stampante/i',
|
||||
partial: '{{succeeded}} riuscito/i, {{failed}} fallito/i',
|
||||
noneApplicable: 'Nessuna stampante selezionata è nello stato corretto per questa azione',
|
||||
selectByState: 'Seleziona per stato',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'Trova Stampanti',
|
||||
|
||||
@@ -382,6 +382,35 @@ export default {
|
||||
powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。',
|
||||
powerOffButton: '電源オフ',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: '選択',
|
||||
selectAll: 'すべて選択',
|
||||
selectByLocation: '場所で選択',
|
||||
selected: '{{count}}件選択中',
|
||||
actions: {
|
||||
stop: '停止',
|
||||
pause: '一時停止',
|
||||
resume: '再開',
|
||||
clearPlate: 'ベッドをクリア',
|
||||
clearHMS: '通知をクリア',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: '{{count}}件の印刷を停止',
|
||||
stopMessage: '{{count}}台のプリンターのアクティブな印刷をキャンセルします。この操作は元に戻せません。',
|
||||
stopButton: 'すべて停止',
|
||||
pauseTitle: '{{count}}件の印刷を一時停止',
|
||||
pauseMessage: '{{count}}台のプリンターのアクティブな印刷を一時停止します。',
|
||||
pauseButton: 'すべて一時停止',
|
||||
clearPlateTitle: '{{count}}台のプリントベッドをクリア',
|
||||
clearPlateMessage: '{{count}}台のプリンターのプリントベッドをクリアし、キュー内のジョブが開始される場合があります。',
|
||||
clearPlateButton: 'すべてクリア',
|
||||
},
|
||||
success: '{{count}}台のプリンターで{{action}}が完了',
|
||||
partial: '{{succeeded}}件成功、{{failed}}件失敗',
|
||||
noneApplicable: '選択したプリンターにこのアクションに適した状態のものがありません',
|
||||
selectByState: 'ステータスで選択',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'プリンター',
|
||||
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',
|
||||
powerOffButton: 'Desligar',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: 'Selecionar',
|
||||
selectAll: 'Selecionar tudo',
|
||||
selectByLocation: 'Selecionar por local',
|
||||
selected: '{{count}} selecionado(s)',
|
||||
actions: {
|
||||
stop: 'Parar',
|
||||
pause: 'Pausar',
|
||||
resume: 'Retomar',
|
||||
clearPlate: 'Limpar mesa',
|
||||
clearHMS: 'Limpar notificações',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: 'Parar {{count}} impressões',
|
||||
stopMessage: 'Isso cancelará as impressões ativas em {{count}} impressora(s). Esta ação não pode ser desfeita.',
|
||||
stopButton: 'Parar todas',
|
||||
pauseTitle: 'Pausar {{count}} impressões',
|
||||
pauseMessage: 'Isso pausará as impressões ativas em {{count}} impressora(s).',
|
||||
pauseButton: 'Pausar todas',
|
||||
clearPlateTitle: 'Limpar {{count}} mesas de impressão',
|
||||
clearPlateMessage: 'Isso limpará a mesa de impressão em {{count}} impressora(s) e pode iniciar trabalhos na fila.',
|
||||
clearPlateButton: 'Limpar todas',
|
||||
},
|
||||
success: '{{action}} concluído em {{count}} impressora(s)',
|
||||
partial: '{{succeeded}} bem-sucedido(s), {{failed}} falhou/falharam',
|
||||
noneApplicable: 'Nenhuma impressora selecionada está no estado correto para esta ação',
|
||||
selectByState: 'Selecionar por estado',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: 'Descobrir Impressoras',
|
||||
|
||||
@@ -383,6 +383,35 @@ export default {
|
||||
powerOffWarning: '警告:"{{name}}"正在打印中!确定要关闭电源吗?这将中断打印并可能损坏打印机。',
|
||||
powerOffButton: '关机',
|
||||
},
|
||||
// Bulk actions
|
||||
bulk: {
|
||||
select: '选择',
|
||||
selectAll: '全选',
|
||||
selectByLocation: '按位置选择',
|
||||
selected: '已选择{{count}}台',
|
||||
actions: {
|
||||
stop: '停止',
|
||||
pause: '暂停',
|
||||
resume: '继续',
|
||||
clearPlate: '清除打印床',
|
||||
clearHMS: '清除通知',
|
||||
},
|
||||
confirm: {
|
||||
stopTitle: '停止{{count}}个打印任务',
|
||||
stopMessage: '这将取消{{count}}台打印机上的活动打印任务。此操作无法撤销。',
|
||||
stopButton: '全部停止',
|
||||
pauseTitle: '暂停{{count}}个打印任务',
|
||||
pauseMessage: '这将暂停{{count}}台打印机上的活动打印任务。',
|
||||
pauseButton: '全部暂停',
|
||||
clearPlateTitle: '清除{{count}}个打印床',
|
||||
clearPlateMessage: '这将清除{{count}}台打印机的打印床,可能会触发队列中的任务。',
|
||||
clearPlateButton: '全部清除',
|
||||
},
|
||||
success: '{{action}}已在{{count}}台打印机上完成',
|
||||
partial: '{{succeeded}}成功,{{failed}}失败',
|
||||
noneApplicable: '没有选中的打印机处于适合此操作的状态',
|
||||
selectByState: '按状态选择',
|
||||
},
|
||||
// Discovery
|
||||
discovery: {
|
||||
title: '发现打印机',
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Download,
|
||||
ScanSearch,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
XCircle,
|
||||
User,
|
||||
Home,
|
||||
@@ -56,6 +57,7 @@ import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdate
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinterToolbar';
|
||||
import { FileManagerModal } from '../components/FileManagerModal';
|
||||
import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
|
||||
import { MQTTDebugModal } from '../components/MQTTDebugModal';
|
||||
@@ -1120,10 +1122,12 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
|
||||
|
||||
const { counts, nextFinish } = useMemo(() => {
|
||||
let printing = 0;
|
||||
let paused = 0;
|
||||
let finished = 0;
|
||||
let idle = 0;
|
||||
let offline = 0;
|
||||
let loading = 0;
|
||||
let problem = 0;
|
||||
let error = 0;
|
||||
let nextPrinterName: string | null = null;
|
||||
let nextRemainingMin: number | null = null;
|
||||
let nextProgress: number = 0;
|
||||
@@ -1138,25 +1142,37 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
|
||||
} else {
|
||||
// Count printers with HMS errors
|
||||
if (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0) {
|
||||
problem++;
|
||||
error++;
|
||||
}
|
||||
if (status.state === 'RUNNING') {
|
||||
printing++;
|
||||
if (status.remaining_time != null && status.remaining_time > 0) {
|
||||
if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
|
||||
nextRemainingMin = status.remaining_time;
|
||||
nextPrinterName = printer.name;
|
||||
nextProgress = status.progress || 0;
|
||||
switch (status.state) {
|
||||
case 'RUNNING':
|
||||
printing++;
|
||||
if (status.remaining_time != null && status.remaining_time > 0) {
|
||||
if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
|
||||
nextRemainingMin = status.remaining_time;
|
||||
nextPrinterName = printer.name;
|
||||
nextProgress = status.progress || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
idle++;
|
||||
break;
|
||||
case 'PAUSE':
|
||||
paused++;
|
||||
break;
|
||||
case 'FINISH':
|
||||
finished++;
|
||||
break;
|
||||
case 'FAILED':
|
||||
error++;
|
||||
break;
|
||||
default:
|
||||
idle++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
counts: { printing, idle, offline, loading, problem, total: (printers?.length || 0) },
|
||||
counts: { printing, paused, finished, idle, offline, loading, error, total: (printers?.length || 0) },
|
||||
nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1164,38 +1180,25 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
|
||||
|
||||
if (!printers?.length) return null;
|
||||
|
||||
const badges: { count: number; dot: string; label: string }[] = [
|
||||
{ count: counts.printing, dot: 'bg-bambu-green animate-pulse', label: t('printers.status.printing').toLowerCase() },
|
||||
{ count: counts.paused, dot: 'bg-status-warning', label: t('printers.status.paused', 'paused').toLowerCase() },
|
||||
{ count: counts.finished, dot: 'bg-blue-400', label: t('printers.status.finished', 'finished').toLowerCase() },
|
||||
{ count: counts.idle, dot: counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500', label: t('printers.status.available').toLowerCase() },
|
||||
{ count: counts.error, dot: 'bg-status-error', label: t('printers.status.problem').toLowerCase() },
|
||||
{ count: counts.offline, dot: 'bg-gray-400', label: t('printers.status.offline').toLowerCase() },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
|
||||
<span className="text-bambu-gray">
|
||||
<span className="text-white font-medium">{counts.idle}</span> {t('printers.status.available').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
{counts.printing > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
|
||||
{badges.map(({ count, dot, label }) => count > 0 && (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${dot}`} />
|
||||
<span className="text-bambu-gray">
|
||||
<span className="text-white font-medium">{counts.printing}</span> {t('printers.status.printing').toLowerCase()}
|
||||
<span className="text-white font-medium">{count}</span> {label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{counts.offline > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400" />
|
||||
<span className="text-bambu-gray">
|
||||
<span className="text-white font-medium">{counts.offline}</span> {t('printers.status.offline').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{counts.problem > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-status-error" />
|
||||
<span className="text-bambu-gray">
|
||||
<span className="text-white font-medium">{counts.problem}</span> {t('printers.status.problem').toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{nextFinish && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-bambu-dark-tertiary" />
|
||||
@@ -1517,6 +1520,9 @@ function PrinterCard({
|
||||
onOpenEmbeddedCamera,
|
||||
checkPrinterFirmware = true,
|
||||
dryingPresets = DRYING_PRESETS,
|
||||
selectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
printer: Printer;
|
||||
hideIfDisconnected?: boolean;
|
||||
@@ -1542,6 +1548,9 @@ function PrinterCard({
|
||||
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
|
||||
checkPrinterFirmware?: boolean;
|
||||
dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -2340,12 +2349,25 @@ function PrinterCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="relative"
|
||||
className={`relative ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
|
||||
onDragEnter={handleCardDragEnter}
|
||||
onDragOver={handleCardDragOver}
|
||||
onDragLeave={handleCardDragLeave}
|
||||
onDrop={handleCardDrop}
|
||||
>
|
||||
{/* Selection mode click overlay — captures all clicks, preventing nested interactions */}
|
||||
{selectionMode && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-start p-2"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelect?.(printer.id); }}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="w-5 h-5 text-bambu-green" />
|
||||
) : (
|
||||
<Square className="w-5 h-5 text-bambu-gray" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Drop zone overlay */}
|
||||
{(isDraggingFile || isDropUploading) && (
|
||||
<div
|
||||
@@ -5903,6 +5925,102 @@ export function PrintersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedPrinterIds, setSelectedPrinterIds] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [bulkConfirmAction, setBulkConfirmAction] = useState<'stop' | 'pause' | 'clearPlate' | null>(null);
|
||||
const [bulkActionPending, setBulkActionPending] = useState(false);
|
||||
const selectionMode = isSelectionMode || selectedPrinterIds.size > 0;
|
||||
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelectedPrinterIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedPrinterIds(new Set());
|
||||
setIsSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
// Escape key exits selection mode
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && selectionMode) {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectionMode, clearSelection]);
|
||||
|
||||
const executeBulkAction = useCallback(async (action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {
|
||||
setBulkActionPending(true);
|
||||
const ids = Array.from(selectedPrinterIds);
|
||||
|
||||
// Filter to only applicable printers based on cached state
|
||||
const applicableIds = ids.filter(id => {
|
||||
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', id]);
|
||||
if (!status?.connected) return false;
|
||||
switch (action) {
|
||||
case 'stop': return status.state === 'RUNNING' || status.state === 'PAUSE';
|
||||
case 'pause': return status.state === 'RUNNING';
|
||||
case 'resume': return status.state === 'PAUSE';
|
||||
case 'clearPlate': return (status.state === 'FINISH' || status.state === 'FAILED') && !(status as { plate_cleared?: boolean }).plate_cleared;
|
||||
case 'clearHMS': return status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (applicableIds.length === 0) {
|
||||
showToast(t('printers.bulk.noneApplicable'), 'error');
|
||||
setBulkActionPending(false);
|
||||
setBulkConfirmAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiCall = {
|
||||
stop: api.stopPrint,
|
||||
pause: api.pausePrint,
|
||||
resume: api.resumePrint,
|
||||
clearPlate: api.clearPlate,
|
||||
clearHMS: api.clearHMSErrors,
|
||||
}[action];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
applicableIds.map(id => apiCall(id))
|
||||
);
|
||||
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
if (failed === 0) {
|
||||
showToast(t('printers.bulk.success', { action: t(`printers.bulk.actions.${action}`), count: succeeded }));
|
||||
} else {
|
||||
showToast(t('printers.bulk.partial', { succeeded, failed }), 'error');
|
||||
}
|
||||
|
||||
// Invalidate status queries for affected printers
|
||||
applicableIds.forEach(id => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatus', id] });
|
||||
});
|
||||
|
||||
setBulkActionPending(false);
|
||||
setBulkConfirmAction(null);
|
||||
}, [selectedPrinterIds, queryClient, showToast, t]);
|
||||
|
||||
const handleBulkAction = useCallback((action: 'stop' | 'pause' | 'resume' | 'clearPlate' | 'clearHMS') => {
|
||||
// Actions that need confirmation
|
||||
if (action === 'stop' || action === 'pause' || action === 'clearPlate') {
|
||||
setBulkConfirmAction(action);
|
||||
} else {
|
||||
executeBulkAction(action);
|
||||
}
|
||||
}, [executeBulkAction]);
|
||||
|
||||
const toggleHideDisconnected = () => {
|
||||
const newValue = !hideDisconnected;
|
||||
setHideDisconnected(newValue);
|
||||
@@ -5982,6 +6100,40 @@ export function PrintersPage() {
|
||||
return sorted;
|
||||
}, [printers, sortBy, sortAsc, queryClient]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedPrinterIds(new Set(sortedPrinters.map(p => p.id)));
|
||||
setIsSelectionMode(true);
|
||||
}, [sortedPrinters]);
|
||||
|
||||
const selectByState = useCallback((state: PrinterState) => {
|
||||
setSelectedPrinterIds(prev => {
|
||||
const next = new Set(prev);
|
||||
sortedPrinters.forEach(p => {
|
||||
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);
|
||||
if (!status) return;
|
||||
switch (state) {
|
||||
case 'printing': if (status.connected && status.state === 'RUNNING') next.add(p.id); break;
|
||||
case 'paused': if (status.connected && status.state === 'PAUSE') next.add(p.id); break;
|
||||
case 'finished': if (status.connected && status.state === 'FINISH') next.add(p.id); break;
|
||||
case 'idle': if (status.connected && status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED') next.add(p.id); break;
|
||||
case 'error': if (status.connected && (status.state === 'FAILED' || (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0))) next.add(p.id); break;
|
||||
case 'offline': if (!status.connected) next.add(p.id); break;
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setIsSelectionMode(true);
|
||||
}, [sortedPrinters, queryClient]);
|
||||
|
||||
const selectByLocation = useCallback((location: string) => {
|
||||
setSelectedPrinterIds(prev => {
|
||||
const next = new Set(prev);
|
||||
sortedPrinters.filter(p => (p.location || '') === location).forEach(p => next.add(p.id));
|
||||
return next;
|
||||
});
|
||||
setIsSelectionMode(true);
|
||||
}, [sortedPrinters]);
|
||||
|
||||
// Group printers by location when sorted by location
|
||||
const groupedPrinters = useMemo(() => {
|
||||
if (sortBy !== 'location') return null;
|
||||
@@ -6057,6 +6209,23 @@ export function PrintersPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bulk select toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectionMode) clearSelection();
|
||||
else setIsSelectionMode(true);
|
||||
}}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
selectionMode
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
title={t('printers.bulk.select')}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-bambu-dark-tertiary" />
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
||||
@@ -6148,6 +6317,14 @@ export function PrintersPage() {
|
||||
<span className="w-2 h-2 rounded-full bg-bambu-green" />
|
||||
{location}
|
||||
<span className="text-sm font-normal text-bambu-gray">({locationPrinters.length})</span>
|
||||
{selectionMode && (
|
||||
<button
|
||||
onClick={() => selectByLocation(location === 'Ungrouped' ? '' : location)}
|
||||
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
|
||||
>
|
||||
{t('printers.bulk.selectAll')}
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
|
||||
{locationPrinters.map((printer) => (
|
||||
@@ -6176,6 +6353,9 @@ export function PrintersPage() {
|
||||
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
|
||||
checkPrinterFirmware={settings?.check_printer_firmware !== false}
|
||||
dryingPresets={effectiveDryingPresets}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedPrinterIds.has(printer.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -6211,6 +6391,9 @@ export function PrintersPage() {
|
||||
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
|
||||
checkPrinterFirmware={settings?.check_printer_firmware !== false}
|
||||
dryingPresets={effectiveDryingPresets}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedPrinterIds.has(printer.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -6224,6 +6407,53 @@ export function PrintersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk selection toolbar */}
|
||||
{selectionMode && printers && (
|
||||
<BulkPrinterToolbar
|
||||
selectedIds={selectedPrinterIds}
|
||||
printers={printers}
|
||||
onClose={clearSelection}
|
||||
onSelectAll={selectAll}
|
||||
onSelectByLocation={selectByLocation}
|
||||
onSelectByState={selectByState}
|
||||
onAction={handleBulkAction}
|
||||
actionPending={bulkActionPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk action confirmation modals */}
|
||||
{bulkConfirmAction === 'stop' && (
|
||||
<ConfirmModal
|
||||
title={t('printers.bulk.confirm.stopTitle', { count: selectedPrinterIds.size })}
|
||||
message={t('printers.bulk.confirm.stopMessage', { count: selectedPrinterIds.size })}
|
||||
confirmText={t('printers.bulk.confirm.stopButton')}
|
||||
variant="danger"
|
||||
isLoading={bulkActionPending}
|
||||
onConfirm={() => executeBulkAction('stop')}
|
||||
onCancel={() => setBulkConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{bulkConfirmAction === 'pause' && (
|
||||
<ConfirmModal
|
||||
title={t('printers.bulk.confirm.pauseTitle', { count: selectedPrinterIds.size })}
|
||||
message={t('printers.bulk.confirm.pauseMessage', { count: selectedPrinterIds.size })}
|
||||
confirmText={t('printers.bulk.confirm.pauseButton')}
|
||||
isLoading={bulkActionPending}
|
||||
onConfirm={() => executeBulkAction('pause')}
|
||||
onCancel={() => setBulkConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{bulkConfirmAction === 'clearPlate' && (
|
||||
<ConfirmModal
|
||||
title={t('printers.bulk.confirm.clearPlateTitle', { count: selectedPrinterIds.size })}
|
||||
message={t('printers.bulk.confirm.clearPlateMessage', { count: selectedPrinterIds.size })}
|
||||
confirmText={t('printers.bulk.confirm.clearPlateButton')}
|
||||
isLoading={bulkActionPending}
|
||||
onConfirm={() => executeBulkAction('clearPlate')}
|
||||
onCancel={() => setBulkConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
|
||||
{Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
|
||||
<EmbeddedCameraViewer
|
||||
|
||||
Reference in New Issue
Block a user