|
@@ -64,14 +64,14 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
|
|
|
import { getCurrencySymbol } from '../utils/currency';
|
|
import { getCurrencySymbol } from '../utils/currency';
|
|
|
import { getBedTypeInfo } from '../utils/bedType';
|
|
import { getBedTypeInfo } from '../utils/bedType';
|
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
|
-import type { Archive, ProjectListItem } from '../api/client';
|
|
|
|
|
|
|
+import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
|
|
|
import { Card, CardContent } from '../components/Card';
|
|
import { Card, CardContent } from '../components/Card';
|
|
|
import { Button } from '../components/Button';
|
|
import { Button } from '../components/Button';
|
|
|
import { PrintModal } from '../components/PrintModal';
|
|
import { PrintModal } from '../components/PrintModal';
|
|
|
import { UploadModal } from '../components/UploadModal';
|
|
import { UploadModal } from '../components/UploadModal';
|
|
|
import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
|
|
import { PurgeArchivesModal } from '../components/PurgeArchivesModal';
|
|
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
|
-import { EditArchiveModal } from '../components/EditArchiveModal';
|
|
|
|
|
|
|
+import { EditArchiveModal, FAILURE_REASON_KEYS } from '../components/EditArchiveModal';
|
|
|
import { PrintLogModal } from '../components/PrintLogModal';
|
|
import { PrintLogModal } from '../components/PrintLogModal';
|
|
|
import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
|
|
import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
|
|
|
import { BatchTagModal } from '../components/BatchTagModal';
|
|
import { BatchTagModal } from '../components/BatchTagModal';
|
|
@@ -2612,6 +2612,11 @@ export function ArchivesPage() {
|
|
|
});
|
|
});
|
|
|
const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
|
|
const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
|
|
|
const [pendingDeleteEntryId, setPendingDeleteEntryId] = useState<number | null>(null);
|
|
const [pendingDeleteEntryId, setPendingDeleteEntryId] = useState<number | null>(null);
|
|
|
|
|
+ // Per-row classification editor for Print Log entries (#1687 part 4).
|
|
|
|
|
+ // Holds the entry being edited; null = modal closed.
|
|
|
|
|
+ const [editingLogEntry, setEditingLogEntry] = useState<PrintLogEntry | null>(null);
|
|
|
|
|
+ const [editingLogFailureReason, setEditingLogFailureReason] = useState('');
|
|
|
|
|
+ const [editingLogStatus, setEditingLogStatus] = useState('');
|
|
|
const [logPageSize, setLogPageSize] = useState(() => {
|
|
const [logPageSize, setLogPageSize] = useState(() => {
|
|
|
const saved = localStorage.getItem('logPageSize');
|
|
const saved = localStorage.getItem('logPageSize');
|
|
|
return saved ? Number(saved) : 25;
|
|
return saved ? Number(saved) : 25;
|
|
@@ -2736,6 +2741,23 @@ export function ArchivesPage() {
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ const updateLogEntryMutation = useMutation({
|
|
|
|
|
+ mutationFn: ({ id, body }: { id: number; body: { failure_reason?: string | null; status?: string } }) =>
|
|
|
|
|
+ api.updatePrintLogEntry(id, body),
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['print-log'] });
|
|
|
|
|
+ // Also invalidate /archives/stats — the Failure Analysis widget there
|
|
|
|
|
+ // groups by PrintLogEntry.failure_reason, so a re-classification needs
|
|
|
|
|
+ // to flow through (#1687 part 4).
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['archives-stats'] });
|
|
|
|
|
+ setEditingLogEntry(null);
|
|
|
|
|
+ showToast(t('archives.log.entryUpdated'));
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => {
|
|
|
|
|
+ showToast(t('archives.log.entryUpdateFailed'), 'error');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// Persist all filters to localStorage
|
|
// Persist all filters to localStorage
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (filterPrinter !== null) {
|
|
if (filterPrinter !== null) {
|
|
@@ -3799,6 +3821,11 @@ export function ArchivesPage() {
|
|
|
}`}>
|
|
}`}>
|
|
|
{entry.status}
|
|
{entry.status}
|
|
|
</span>
|
|
</span>
|
|
|
|
|
+ {entry.failure_reason && (
|
|
|
|
|
+ <span className="block text-[10px] text-bambu-gray mt-0.5">
|
|
|
|
|
+ {t(`editArchive.failureReasons.${entry.failure_reason}`, { defaultValue: entry.failure_reason })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
</td>
|
|
</td>
|
|
|
<td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
|
|
<td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
|
|
|
{entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
|
|
{entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
|
|
@@ -3817,22 +3844,44 @@ export function ArchivesPage() {
|
|
|
</div>
|
|
</div>
|
|
|
</td>
|
|
</td>
|
|
|
<td className="px-4 py-3 text-right">
|
|
<td className="px-4 py-3 text-right">
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => setPendingDeleteEntryId(entry.id)}
|
|
|
|
|
- disabled={
|
|
|
|
|
- deleteLogEntryMutation.isPending ||
|
|
|
|
|
- !(hasPermission('archives:delete_all') || hasPermission('archives:delete_own'))
|
|
|
|
|
- }
|
|
|
|
|
- title={
|
|
|
|
|
- hasPermission('archives:delete_all') || hasPermission('archives:delete_own')
|
|
|
|
|
- ? t('archives.log.deleteEntryTitle')
|
|
|
|
|
- : t('archives.permission.noDelete')
|
|
|
|
|
- }
|
|
|
|
|
- className="text-bambu-gray hover:text-red-400 disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
|
|
|
|
|
- >
|
|
|
|
|
- <Trash2 className="w-4 h-4" />
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <div className="inline-flex items-center gap-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setEditingLogEntry(entry);
|
|
|
|
|
+ setEditingLogFailureReason(entry.failure_reason || '');
|
|
|
|
|
+ setEditingLogStatus(entry.status);
|
|
|
|
|
+ }}
|
|
|
|
|
+ disabled={
|
|
|
|
|
+ updateLogEntryMutation.isPending ||
|
|
|
|
|
+ !(hasPermission('archives:update_all') || hasPermission('archives:update_own'))
|
|
|
|
|
+ }
|
|
|
|
|
+ title={
|
|
|
|
|
+ hasPermission('archives:update_all') || hasPermission('archives:update_own')
|
|
|
|
|
+ ? t('archives.log.editEntryTitle')
|
|
|
|
|
+ : t('archives.permission.noEdit')
|
|
|
|
|
+ }
|
|
|
|
|
+ className="text-bambu-gray hover:text-bambu-blue disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Pencil className="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => setPendingDeleteEntryId(entry.id)}
|
|
|
|
|
+ disabled={
|
|
|
|
|
+ deleteLogEntryMutation.isPending ||
|
|
|
|
|
+ !(hasPermission('archives:delete_all') || hasPermission('archives:delete_own'))
|
|
|
|
|
+ }
|
|
|
|
|
+ title={
|
|
|
|
|
+ hasPermission('archives:delete_all') || hasPermission('archives:delete_own')
|
|
|
|
|
+ ? t('archives.log.deleteEntryTitle')
|
|
|
|
|
+ : t('archives.permission.noDelete')
|
|
|
|
|
+ }
|
|
|
|
|
+ className="text-bambu-gray hover:text-red-400 disabled:opacity-40 disabled:hover:text-bambu-gray transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
</td>
|
|
</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
))}
|
|
))}
|
|
@@ -3972,6 +4021,97 @@ export function ArchivesPage() {
|
|
|
onCancel={() => setPendingDeleteEntryId(null)}
|
|
onCancel={() => setPendingDeleteEntryId(null)}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Per-row Print Log classification editor (#1687 part 4).
|
|
|
|
|
+ Reuses editArchive.failureReasons.* and archives.log.statuses.*
|
|
|
|
|
+ vocabularies so the dropdown stays in lockstep with the
|
|
|
|
|
+ Archive Edit modal — backend validates against the same list. */}
|
|
|
|
|
+ {editingLogEntry !== null && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
|
|
|
+ onClick={() => setEditingLogEntry(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-md mx-4 p-6"
|
|
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <h3 className="text-lg font-semibold text-white mb-4">
|
|
|
|
|
+ {t('archives.log.editEntryTitle')}
|
|
|
|
|
+ </h3>
|
|
|
|
|
+ <p className="text-xs text-bambu-gray mb-4">
|
|
|
|
|
+ {t('archives.log.editEntryDescription')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-sm text-bambu-gray mb-1">
|
|
|
|
|
+ {t('editArchive.status')}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={editingLogStatus}
|
|
|
|
|
+ onChange={(e) => setEditingLogStatus(e.target.value)}
|
|
|
|
|
+ className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="completed">{t('archives.log.statuses.completed', { defaultValue: 'completed' })}</option>
|
|
|
|
|
+ <option value="failed">{t('archives.log.statuses.failed', { defaultValue: 'failed' })}</option>
|
|
|
|
|
+ <option value="stopped">{t('archives.log.statuses.stopped', { defaultValue: 'stopped' })}</option>
|
|
|
|
|
+ <option value="cancelled">{t('archives.log.statuses.cancelled', { defaultValue: 'cancelled' })}</option>
|
|
|
|
|
+ <option value="skipped">{t('archives.log.statuses.skipped', { defaultValue: 'skipped' })}</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-sm text-bambu-gray mb-1">
|
|
|
|
|
+ {t('editArchive.failureReason')}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={editingLogFailureReason}
|
|
|
|
|
+ onChange={(e) => setEditingLogFailureReason(e.target.value)}
|
|
|
|
|
+ className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="">{t('editArchive.selectReason')}</option>
|
|
|
|
|
+ {FAILURE_REASON_KEYS.map((key) => (
|
|
|
|
|
+ <option key={key} value={key}>
|
|
|
|
|
+ {t(`editArchive.failureReasons.${key}`)}
|
|
|
|
|
+ </option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex justify-end gap-2 mt-6">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={() => setEditingLogEntry(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t('common.cancel')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="primary"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const body: { failure_reason?: string | null; status?: string } = {};
|
|
|
|
|
+ // Send failure_reason only if it changed — empty string
|
|
|
|
|
+ // clears the classification (backend stores it as NULL).
|
|
|
|
|
+ if (editingLogFailureReason !== (editingLogEntry.failure_reason || '')) {
|
|
|
|
|
+ body.failure_reason = editingLogFailureReason || null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (editingLogStatus !== editingLogEntry.status) {
|
|
|
|
|
+ body.status = editingLogStatus;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Object.keys(body).length === 0) {
|
|
|
|
|
+ setEditingLogEntry(null);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ updateLogEntryMutation.mutate({ id: editingLogEntry.id, body });
|
|
|
|
|
+ }}
|
|
|
|
|
+ disabled={updateLogEntryMutation.isPending}
|
|
|
|
|
+ >
|
|
|
|
|
+ {updateLogEntryMutation.isPending ? t('editArchive.saving') : t('common.save')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|