Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047)

This commit is contained in:
Thomansky
2026-09-26 15:37:19 +02:00
committed by GitHub
parent 4e83751f71
commit 44c7e6fb39
75 changed files with 5752 additions and 74 deletions
@@ -45,6 +45,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
// Post-print outcome confirmation (#1898). Defaults ON — it only fires for
// prints that opted in per-job, so the toggle exists to mute a channel.
const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
@@ -205,6 +208,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_stock_reorder_alert: onStockReorderAlert,
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_print_confirm_request: onPrintConfirmRequest,
on_bed_cooled: onBedCooled,
on_ha_sensor_alert: onHaSensorAlert,
on_location_ha_sensor_alert: onLocationHaSensorAlert,
@@ -625,6 +629,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
</div>
<Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.printConfirmRequest')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.printConfirmRequestDescription')}</span>
</div>
<Toggle checked={onPrintConfirmRequest} onChange={setOnPrintConfirmRequest} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.bedCooled')}</span>
@@ -714,6 +725,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') });
if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
@@ -0,0 +1,208 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ThumbsUp, ThumbsDown, X, RotateCcw, ImageOff } from 'lucide-react';
import { api } from '../api/client';
import { FAILURE_REASON_KEYS } from './EditArchiveModal';
import { verdictSourceKey } from '../utils/verdictSource';
import { PrintModal } from './PrintModal';
import { useToast } from '../contexts/ToastContext';
interface ConfirmOutcomeDialogProps {
archiveId: number;
onClose: () => void;
}
/**
* Post-print outcome confirmation (#1898), Formlabs-style: finish photo plus
* Good / Reject. Opens from the print_confirm_request WebSocket event (via
* Layout), from the pending badge on an archive card, and from the
* ?confirm=<id> deep link a push notification carries. Rejecting offers an
* optional reason and a one-click reprint through the regular PrintModal.
*/
export function ConfirmOutcomeDialog({ archiveId, onClose }: ConfirmOutcomeDialogProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [showReprint, setShowReprint] = useState(false);
const { data: archive } = useQuery({
queryKey: ['archive', archiveId],
queryFn: () => api.getArchive(archiveId),
});
const verdictMutation = useMutation({
mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) =>
api.updateArchive(archiveId, { ...data, user_verdict_source: 'dialog' }),
onSuccess: (_updated, variables) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });
showToast(
variables.user_verdict === 'good'
? t('confirmOutcome.savedGood')
: t('confirmOutcome.savedReject'),
'success',
);
if (variables.user_verdict === 'good') {
onClose();
}
},
onError: () => showToast(t('confirmOutcome.saveFailed'), 'error'),
});
const name = archive?.print_name || archive?.filename || '';
// The finish photo is prepended to archive.photos at print end (#1397);
// fall back to the thumbnail when no camera shot exists.
const photoFilename = archive?.photos?.[0] ?? null;
const photoUrl = photoFilename ? api.getArchivePhotoUrl(archiveId, photoFilename) : null;
const alreadyDecided = archive?.user_verdict != null;
// Why this print already has a verdict — the plate-clear default answers
// prompts on its own, and without this the answer looks like it appeared
// from nowhere (#1898).
const sourceKey = verdictSourceKey(archive?.user_verdict_source);
const handleGood = () => verdictMutation.mutate({ user_verdict: 'good' });
const handleRejectSave = (reprint: boolean) => {
verdictMutation.mutate(
{ user_verdict: 'reject', ...(rejectReason ? { failure_reason: rejectReason } : {}) },
{
onSuccess: () => {
if (reprint) {
setShowReprint(true);
} else {
onClose();
}
},
},
);
};
if (showReprint && archive) {
return (
<PrintModal
mode="create"
archiveId={archiveId}
archiveName={name}
onClose={onClose}
/>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" data-testid="confirm-outcome-dialog">
<div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
<h3 className="text-white font-semibold">{t('confirmOutcome.title')}</h3>
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors" title={t('common.close')}>
<X className="w-5 h-5" />
</button>
</div>
{/* Photo */}
<div className="bg-bambu-dark aspect-video flex items-center justify-center overflow-hidden">
{photoUrl ? (
<img src={photoUrl} alt={name} className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center gap-2 text-bambu-gray/60">
<ImageOff className="w-10 h-10" />
<span className="text-xs">{t('confirmOutcome.noPhoto')}</span>
</div>
)}
</div>
<div className="p-4">
<p className="text-sm text-white text-center mb-1 truncate" title={name}>{name}</p>
{alreadyDecided ? (
<>
<p className="text-sm text-bambu-gray text-center">
{archive?.user_verdict === 'good'
? t('confirmOutcome.alreadyGood')
: t('confirmOutcome.alreadyRejected')}
</p>
{sourceKey && (
<p className="text-xs text-bambu-gray/70 text-center mt-1" data-testid="verdict-source-hint">
{t(sourceKey)}
</p>
)}
</>
) : !rejecting ? (
<div className="flex items-center justify-center gap-6 mt-3">
<button
type="button"
onClick={() => setRejecting(true)}
disabled={verdictMutation.isPending}
className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-red-500/40 text-red-500 hover:bg-red-500/10 transition-colors"
>
<ThumbsDown className="w-7 h-7" />
<span className="text-xs">{t('confirmOutcome.reject')}</span>
</button>
<button
type="button"
onClick={handleGood}
disabled={verdictMutation.isPending}
className="flex flex-col items-center gap-1.5 px-6 py-3 rounded-xl border border-bambu-green/50 text-bambu-green hover:bg-bambu-green/10 transition-colors"
>
<ThumbsUp className="w-7 h-7" />
<span className="text-xs">{t('confirmOutcome.good')}</span>
</button>
</div>
) : (
<div className="mt-3">
<label className="block text-xs text-bambu-gray mb-1">{t('confirmOutcome.rejectReason')}</label>
<select
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value="">{t('confirmOutcome.noReason')}</option>
{FAILURE_REASON_KEYS.map((key) => (
<option key={key} value={key}>{t(`editArchive.failureReasons.${key}`)}</option>
))}
</select>
<div className="flex gap-2 mt-3">
<button
type="button"
onClick={() => handleRejectSave(false)}
disabled={verdictMutation.isPending}
className="flex-1 px-3 py-2 rounded-lg bg-red-500/80 hover:bg-red-500 text-white text-sm transition-colors"
>
{t('confirmOutcome.saveReject')}
</button>
<button
type="button"
onClick={() => handleRejectSave(true)}
disabled={verdictMutation.isPending}
className="flex-1 px-3 py-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green/60 text-white text-sm transition-colors flex items-center justify-center gap-1.5"
>
<RotateCcw className="w-4 h-4" />
{t('confirmOutcome.rejectAndReprint')}
</button>
</div>
<button
type="button"
onClick={() => setRejecting(false)}
className="w-full mt-2 text-xs text-bambu-gray hover:text-white transition-colors"
>
{t('common.back')}
</button>
</div>
)}
{!alreadyDecided && !rejecting && (
<button
type="button"
onClick={onClose}
className="w-full mt-4 text-xs text-bambu-gray hover:text-white transition-colors"
>
{t('confirmOutcome.later')}
</button>
)}
</div>
</div>
</div>
);
}
+52 -5
View File
@@ -8,6 +8,7 @@ import { Button } from './Button';
import { PrintLogTable } from './PrintLogTable';
import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
import { assignableProjects } from '../utils/projectTree';
import { verdictSourceKey } from '../utils/verdictSource';
// Keys for failure reasons - translated at render time.
// Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -89,6 +90,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
? failureReason
: null;
const [status, setStatus] = useState(archive.status);
// Post-print quality verdict (#1898): '' = none, 'good' / 'reject'
const [userVerdict, setUserVerdict] = useState<string>(archive.user_verdict ?? '');
const verdictSourceHintKey = verdictSourceKey(archive.user_verdict_source);
const [quantity, setQuantity] = useState(archive.quantity ?? 1);
// Kept as a string so the field can be genuinely empty: a print archived
// without its 3MF has no figure at all, and "" has to stay distinguishable
@@ -246,6 +250,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
updateData.status = status;
}
// Verdict only when touched, same reasoning as status; '' clears it.
if (userVerdict !== (archive.user_verdict ?? '')) {
updateData.user_verdict = userVerdict === '' ? null : (userVerdict as 'good' | 'reject');
updateData.user_verdict_source = 'dialog';
}
// Sent only when the user actually touched it, so an ordinary save of an
// archive that has its 3MF cannot overwrite the sliced figure with a
// rounded one from the input.
@@ -262,11 +272,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
updateData.filament_used_grams = parsedGrams;
}
// Handle failure_reason based on status
// Handle failure_reason based on status. A completed-but-rejected print
// (#1898) uses the same field as its reject reason.
if (status === 'failed' || status === 'aborted') {
updateData.failure_reason = failureReason || undefined;
} else if (archive.status === 'failed' || archive.status === 'aborted') {
// Clear failure_reason when changing from failed/aborted to another status
} else if (status === 'completed' && userVerdict === 'reject') {
updateData.failure_reason = failureReason || undefined;
} else if (archive.status === 'failed' || archive.status === 'aborted' || archive.user_verdict === 'reject') {
// Clear failure_reason when neither a failure nor a reject carries one.
// Gated on the archive having held a reject verdict rather than on the
// field merely being populated: a 'cancelled' or 'stopped' archive is
// neither failed nor aborted, its reason is written by the machine
// ("Stopped by user (printer was offline)"), and the status dropdown has
// no option that would let the user put it back.
updateData.failure_reason = null;
}
@@ -529,8 +547,37 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
</select>
</div>
{/* Failure Reason - only show for failed/aborted prints */}
{(status === 'failed' || status === 'aborted') && (
{/* Outcome verdict (#1898) - only meaningful on completed prints */}
{status === 'completed' && (
<div>
<label htmlFor="user-verdict-select" className="block text-sm text-bambu-gray mb-1">
{t('editArchive.userVerdict')}
</label>
<select
id="user-verdict-select"
value={userVerdict}
onChange={(e) => setUserVerdict(e.target.value)}
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="">{t('editArchive.verdicts.none')}</option>
<option value="good">{t('editArchive.verdicts.good')}</option>
<option value="reject">{t('editArchive.verdicts.reject')}</option>
</select>
{/* How the stored verdict got here (#1898) — hidden once the
select no longer shows that verdict, since the hint would
then describe an answer the user is replacing. */}
{verdictSourceHintKey && userVerdict === (archive.user_verdict ?? '') && (
<p className="text-xs text-bambu-gray/70 mt-1" data-testid="verdict-source-hint">
{t(verdictSourceHintKey)}
</p>
)}
</div>
)}
{/* Failure Reason - shown for failed/aborted prints and for
completed-but-rejected ones (#1898: the reject reason reuses
this field and vocabulary) */}
{(status === 'failed' || status === 'aborted' || (status === 'completed' && userVerdict === 'reject')) && (
<div>
<label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
<select
+56 -1
View File
@@ -14,6 +14,7 @@ import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
import { UnknownSpoolModal } from './UnknownSpoolModal';
import { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
@@ -100,7 +101,7 @@ export function Layout() {
// catalog fetched — and cached HSL-fallback color names during their first
// render — refresh with the real catalog names. See #857.
useColorCatalogVersion();
const { user, authEnabled, logout, hasPermission } = useAuth();
const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
const { showToast } = useToast();
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
@@ -124,6 +125,9 @@ export function Layout() {
printer_name: string;
message: string;
} | null>(null);
// Post-print outcome confirmation (#1898): archive waiting for a verdict,
// set by the print_confirm_request WebSocket event.
const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState<number | null>(null);
// Check for updates
const { data: versionInfo } = useQuery({
@@ -460,6 +464,43 @@ export function Layout() {
return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
}, [hasPermission]);
// A completed print asked for its outcome verdict (#1898). Same CustomEvent
// relay as plate-not-empty, and gated the same way: the PATCH route decides
// who may record a verdict, so a user who cannot should not be handed a
// dialog whose only outcome is a 403.
// Held until the auth state has landed: while it is loading, `authEnabled`
// is still false and every permission check answers yes, which would open
// the dialog for exactly the user this gate exists to spare.
const canConfirmOutcome = !authLoading && hasAnyPermission('archives:update_all', 'archives:update_own');
useEffect(() => {
const handleConfirmRequest = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (typeof detail?.archive_id === 'number') {
setConfirmOutcomeArchiveId(detail.archive_id);
}
};
window.addEventListener('print-confirm-request', handleConfirmRequest);
return () => window.removeEventListener('print-confirm-request', handleConfirmRequest);
}, []);
// The ?confirm=<id> deep link a push notification carries (#1898). It is read
// here and not on ArchivesPage because that page renders inside this Layout's
// <Outlet />: React flushes a child's effects before its parent's, so a page
// dispatching `print-confirm-request` on mount fired before the listener above
// existed — which is exactly the cold load a notification tap produces. The
// parameter is stripped afterwards so a reload does not re-open the dialog.
useEffect(() => {
const params = new URLSearchParams(location.search);
const confirmId = params.get('confirm');
if (!confirmId || !/^\d+$/.test(confirmId)) {
return;
}
setConfirmOutcomeArchiveId(Number(confirmId));
params.delete('confirm');
const rest = params.toString();
navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true });
}, [location.pathname, location.search, navigate]);
// Global keyboard shortcuts for navigation
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const target = e.target as HTMLElement;
@@ -1021,6 +1062,20 @@ export function Layout() {
</div>
)}
{/* Post-print outcome confirmation (#1898) */}
{/* The dialog's only outcome for a user who may not record a verdict is
a 403 from the PATCH, so it is gated on the permissions that route
enforces — the names the June migration left in the default groups.
Gated on the render rather than on the two ways a request arrives:
one of them can land before /auth/me has answered, and a check made
then would drop it for everybody. */}
{canConfirmOutcome && confirmOutcomeArchiveId !== null && (
<ConfirmOutcomeDialog
archiveId={confirmOutcomeArchiveId}
onClose={() => setConfirmOutcomeArchiveId(null)}
/>
)}
{/* Change Password Modal */}
{showChangePasswordModal && (
<div
@@ -126,6 +126,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_plate_clear_required && (
<span className="px-2 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs rounded">{t('notifications.plateClear')}</span>
)}
{provider.on_print_confirm_request && (
<span className="px-2 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-xs rounded">{t('notifications.printConfirmRequest')}</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 text-xs rounded">{t('notifications.failed')}</span>
)}
@@ -314,6 +317,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.printConfirmRequest')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.printConfirmRequestDescription')}</p>
</div>
<Toggle
checked={provider.on_print_confirm_request ?? true}
onChange={(checked) => updateMutation.mutate({ on_print_confirm_request: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.bedCooledLabel')}</p>
@@ -49,6 +49,8 @@ export function PrintOptionsPanel({
{ key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
{ key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
{ key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true, tristate: true },
// Post-print outcome confirmation (#1898) — same settings.default* reuse.
{ key: 'confirm_outcome', label: t('settings.defaultConfirmOutcome'), desc: t('settings.defaultConfirmOutcomeDesc') },
];
const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);
+21 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, ThumbsUp, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
@@ -175,6 +175,7 @@ export function PrintModal({
layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: queueItem.nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
confirm_outcome: queueItem.confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
preheat_override: queueItem.preheat_override ?? DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
};
@@ -325,6 +326,7 @@ export function PrintModal({
layer_inspect: settings.default_layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: settings.default_timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
nozzle_offset_cali: settings.default_nozzle_offset_cali ?? DEFAULT_PRINT_OPTIONS.nozzle_offset_cali,
confirm_outcome: settings.default_confirm_outcome ?? DEFAULT_PRINT_OPTIONS.confirm_outcome,
preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
});
@@ -1978,6 +1980,24 @@ export function PrintModal({
hasGcodeSnippets={!!settings?.gcode_snippets}
/>
{/* Outcome prompt (#1898) sits outside the collapsed Print Options
panel so it is discoverable; it edits the same printOptions
field as the row inside the panel. */}
<button
type="button"
aria-pressed={printOptions.confirm_outcome}
title={t('printModal.askForOutcomeTitle')}
onClick={() => setPrintOptions((prev) => ({ ...prev, confirm_outcome: !prev.confirm_outcome }))}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm transition-colors ${
printOptions.confirm_outcome
? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
>
<ThumbsUp className="w-4 h-4" />
{t('printModal.askForOutcome')}
</button>
{/* Error message */}
{updateQueueMutation.isError && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
@@ -66,6 +66,8 @@ export interface PrintOptions {
layer_inspect: boolean;
timelapse: boolean;
nozzle_offset_cali: CalibrationMode;
// Ask for a post-print outcome verdict when this job completes (#1898)
confirm_outcome: boolean;
// Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
// Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
// chamber_target_override is non-null to bypass the per-filament-type
@@ -84,6 +86,7 @@ export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
layer_inspect: false,
timelapse: false,
nozzle_offset_cali: 'auto',
confirm_outcome: false,
preheat_override: 'inherit',
preheat_chamber_target_override: null,
};