mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user