mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch 'feature/mobile_view_fix' of https://github.com/Keybored02/bambuddy into feature/mobile_view_fix
This commit is contained in:
@@ -369,6 +369,28 @@ export interface Archive {
|
||||
created_by_username: string | null;
|
||||
}
|
||||
|
||||
export interface PrintLogEntry {
|
||||
id: number;
|
||||
print_name: string | null;
|
||||
printer_name: string | null;
|
||||
printer_id: number | null;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
duration_seconds: number | null;
|
||||
filament_type: string | null;
|
||||
filament_color: string | null;
|
||||
filament_used_grams: number | null;
|
||||
thumbnail_path: string | null;
|
||||
created_by_username: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PrintLogResponse {
|
||||
items: PrintLogEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ArchiveStats {
|
||||
total_prints: number;
|
||||
successful_prints: number;
|
||||
@@ -2954,6 +2976,32 @@ export const api = {
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Print Log
|
||||
getPrintLog: (params?: {
|
||||
search?: string;
|
||||
printerId?: number;
|
||||
username?: string;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
|
||||
if (params?.username) searchParams.set('created_by_username', params.username);
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
|
||||
if (params?.dateTo) searchParams.set('date_to', params.dateTo);
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
|
||||
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
|
||||
},
|
||||
getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
|
||||
clearPrintLog: () =>
|
||||
request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
|
||||
|
||||
// Settings
|
||||
getSettings: () => request<AppSettings>('/settings/'),
|
||||
updateSettings: (data: AppSettingsUpdate) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, type LucideIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
@@ -29,7 +29,7 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
|
||||
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
|
||||
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
|
||||
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
@@ -484,6 +484,7 @@ export default {
|
||||
gridView: 'Rasteransicht',
|
||||
listView: 'Listenansicht',
|
||||
calendarView: 'Kalenderansicht',
|
||||
logView: 'Druckprotokoll',
|
||||
manageTags: 'Tags verwalten',
|
||||
showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
|
||||
hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
|
||||
@@ -672,6 +673,34 @@ export default {
|
||||
actions: 'Aktionen',
|
||||
hasTimelapse: 'Hat Zeitraffer',
|
||||
},
|
||||
log: {
|
||||
date: 'Datum',
|
||||
printName: 'Druckname',
|
||||
printer: 'Drucker',
|
||||
user: 'Benutzer',
|
||||
status: 'Status',
|
||||
duration: 'Dauer',
|
||||
filament: 'Filament',
|
||||
allPrinters: 'Alle Drucker',
|
||||
allUsers: 'Alle Benutzer',
|
||||
allStatuses: 'Alle Status',
|
||||
cancelled: 'Abgebrochen',
|
||||
skipped: 'Übersprungen',
|
||||
dateFrom: 'Von',
|
||||
dateTo: 'Bis',
|
||||
noEntries: 'Keine Druckprotokolleinträge gefunden',
|
||||
showing: '{{count}} von {{total}} Einträgen',
|
||||
rowsPerPage: 'Zeilen',
|
||||
page: 'Seite',
|
||||
prev: 'Zurück',
|
||||
next: 'Weiter',
|
||||
clearLog: 'Protokoll löschen',
|
||||
clearLogTitle: 'Druckprotokoll löschen',
|
||||
clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',
|
||||
clearLogButton: 'Alle löschen',
|
||||
cleared: '{{count}} Protokolleinträge gelöscht',
|
||||
clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
|
||||
@@ -484,6 +484,7 @@ export default {
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
calendarView: 'Calendar view',
|
||||
logView: 'Print Log',
|
||||
manageTags: 'Manage Tags',
|
||||
showFailedPrints: 'Show failed prints',
|
||||
hideFailedPrints: 'Hide failed prints',
|
||||
@@ -672,6 +673,34 @@ export default {
|
||||
actions: 'Actions',
|
||||
hasTimelapse: 'Has timelapse',
|
||||
},
|
||||
log: {
|
||||
date: 'Date',
|
||||
printName: 'Print Name',
|
||||
printer: 'Printer',
|
||||
user: 'User',
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
filament: 'Filament',
|
||||
allPrinters: 'All Printers',
|
||||
allUsers: 'All Users',
|
||||
allStatuses: 'All Statuses',
|
||||
cancelled: 'Cancelled',
|
||||
skipped: 'Skipped',
|
||||
dateFrom: 'From',
|
||||
dateTo: 'To',
|
||||
noEntries: 'No print log entries found',
|
||||
showing: 'Showing {{count}} of {{total}} entries',
|
||||
rowsPerPage: 'Rows',
|
||||
page: 'Page',
|
||||
prev: 'Prev',
|
||||
next: 'Next',
|
||||
clearLog: 'Clear Log',
|
||||
clearLogTitle: 'Clear Print Log',
|
||||
clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',
|
||||
clearLogButton: 'Clear All',
|
||||
cleared: '{{count}} log entries cleared',
|
||||
clearFailed: 'Failed to clear print log',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
|
||||
@@ -484,6 +484,7 @@ export default {
|
||||
gridView: 'Grille',
|
||||
listView: 'Liste',
|
||||
calendarView: 'Calendrier',
|
||||
logView: 'Journal d\'impression',
|
||||
manageTags: 'Gérer les tags',
|
||||
showFailedPrints: 'Afficher les échecs',
|
||||
hideFailedPrints: 'Masquer les échecs',
|
||||
@@ -672,6 +673,34 @@ export default {
|
||||
actions: 'Actions',
|
||||
hasTimelapse: 'A un timelapse',
|
||||
},
|
||||
log: {
|
||||
date: 'Date',
|
||||
printName: 'Nom de l\'impression',
|
||||
printer: 'Imprimante',
|
||||
user: 'Utilisateur',
|
||||
status: 'Statut',
|
||||
duration: 'Durée',
|
||||
filament: 'Filament',
|
||||
allPrinters: 'Toutes les imprimantes',
|
||||
allUsers: 'Tous les utilisateurs',
|
||||
allStatuses: 'Tous les statuts',
|
||||
cancelled: 'Annulé',
|
||||
skipped: 'Ignoré',
|
||||
dateFrom: 'Du',
|
||||
dateTo: 'Au',
|
||||
noEntries: 'Aucune entrée de journal trouvée',
|
||||
showing: '{{count}} sur {{total}} entrées',
|
||||
rowsPerPage: 'Lignes',
|
||||
page: 'Page',
|
||||
prev: 'Préc.',
|
||||
next: 'Suiv.',
|
||||
clearLog: 'Effacer le journal',
|
||||
clearLogTitle: 'Effacer le journal d\'impression',
|
||||
clearLogConfirm: 'Toutes les entrées du journal d\'impression seront supprimées définitivement. Les archives et les éléments de file d\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',
|
||||
clearLogButton: 'Tout effacer',
|
||||
cleared: '{{count}} entrées de journal effacées',
|
||||
clearFailed: 'Échec de l\'effacement du journal d\'impression',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
|
||||
@@ -475,6 +475,7 @@ export default {
|
||||
gridView: 'Vista griglia',
|
||||
listView: 'Vista elenco',
|
||||
calendarView: 'Vista calendario',
|
||||
logView: 'Registro stampe',
|
||||
manageTags: 'Gestisci tag',
|
||||
showFailedPrints: 'Mostra stampe fallite',
|
||||
hideFailedPrints: 'Nascondi stampe fallite',
|
||||
@@ -663,6 +664,34 @@ export default {
|
||||
actions: 'Azioni',
|
||||
hasTimelapse: 'Ha timelapse',
|
||||
},
|
||||
log: {
|
||||
date: 'Data',
|
||||
printName: 'Nome stampa',
|
||||
printer: 'Stampante',
|
||||
user: 'Utente',
|
||||
status: 'Stato',
|
||||
duration: 'Durata',
|
||||
filament: 'Filamento',
|
||||
allPrinters: 'Tutte le stampanti',
|
||||
allUsers: 'Tutti gli utenti',
|
||||
allStatuses: 'Tutti gli stati',
|
||||
cancelled: 'Annullato',
|
||||
skipped: 'Saltato',
|
||||
dateFrom: 'Dal',
|
||||
dateTo: 'Al',
|
||||
noEntries: 'Nessuna voce di registro trovata',
|
||||
showing: '{{count}} di {{total}} voci',
|
||||
rowsPerPage: 'Righe',
|
||||
page: 'Pagina',
|
||||
prev: 'Prec.',
|
||||
next: 'Succ.',
|
||||
clearLog: 'Cancella registro',
|
||||
clearLogTitle: 'Cancella registro stampe',
|
||||
clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',
|
||||
clearLogButton: 'Cancella tutto',
|
||||
cleared: '{{count}} voci di registro cancellate',
|
||||
clearFailed: 'Impossibile cancellare il registro stampe',
|
||||
},
|
||||
},
|
||||
|
||||
// Queue page
|
||||
|
||||
@@ -484,6 +484,7 @@ export default {
|
||||
gridView: 'グリッド表示',
|
||||
listView: 'リスト表示',
|
||||
calendarView: 'カレンダー表示',
|
||||
logView: '印刷ログ',
|
||||
showFailedPrints: '失敗した印刷を表示',
|
||||
hideFailedPrints: '失敗した印刷を非表示',
|
||||
printTime: '印刷時間',
|
||||
@@ -677,6 +678,34 @@ export default {
|
||||
date: '日付',
|
||||
actions: '操作',
|
||||
},
|
||||
log: {
|
||||
date: '日時',
|
||||
printName: '印刷名',
|
||||
printer: 'プリンター',
|
||||
user: 'ユーザー',
|
||||
status: 'ステータス',
|
||||
duration: '所要時間',
|
||||
filament: 'フィラメント',
|
||||
allPrinters: '全プリンター',
|
||||
allUsers: '全ユーザー',
|
||||
allStatuses: '全ステータス',
|
||||
cancelled: 'キャンセル',
|
||||
skipped: 'スキップ',
|
||||
dateFrom: '開始日',
|
||||
dateTo: '終了日',
|
||||
noEntries: '印刷ログが見つかりません',
|
||||
showing: '{{total}}件中{{count}}件を表示',
|
||||
rowsPerPage: '行数',
|
||||
page: 'ページ',
|
||||
prev: '前へ',
|
||||
next: '次へ',
|
||||
clearLog: 'ログをクリア',
|
||||
clearLogTitle: '印刷ログをクリア',
|
||||
clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか?',
|
||||
clearLogButton: 'すべてクリア',
|
||||
cleared: '{{count}}件のログエントリを削除しました',
|
||||
clearFailed: '印刷ログの削除に失敗しました',
|
||||
},
|
||||
noPrinterAvailable: '利用可能なプリンターがありません',
|
||||
archiveOrReprint: 'アーカイブまたは再印刷',
|
||||
multiPrinterPrint: 'マルチプリンター印刷',
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
ChevronRight,
|
||||
Settings,
|
||||
User,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { openInSlicer, type SlicerType } from '../utils/slicer';
|
||||
@@ -2064,7 +2065,7 @@ function ArchiveListRow({
|
||||
}
|
||||
|
||||
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
|
||||
type ViewMode = 'grid' | 'list' | 'calendar';
|
||||
type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
|
||||
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
|
||||
|
||||
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
|
||||
@@ -2133,6 +2134,29 @@ export function ArchivesPage() {
|
||||
const [showTagManagement, setShowTagManagement] = useState(false);
|
||||
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
|
||||
|
||||
// Log view state
|
||||
const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
|
||||
localStorage.getItem('logFilterUser') || null
|
||||
);
|
||||
const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
|
||||
localStorage.getItem('logFilterStatus')
|
||||
);
|
||||
const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
|
||||
localStorage.getItem('logFilterDateFrom') || ''
|
||||
);
|
||||
const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
|
||||
localStorage.getItem('logFilterDateTo') || ''
|
||||
);
|
||||
const [logOffset, setLogOffset] = useState(() => {
|
||||
const saved = localStorage.getItem('logOffset');
|
||||
return saved ? Number(saved) : 0;
|
||||
});
|
||||
const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
|
||||
const [logPageSize, setLogPageSize] = useState(() => {
|
||||
const saved = localStorage.getItem('logPageSize');
|
||||
return saved ? Number(saved) : 25;
|
||||
});
|
||||
|
||||
// Clear highlight after 5 seconds and scroll to highlighted element
|
||||
useEffect(() => {
|
||||
if (highlightedArchiveId) {
|
||||
@@ -2173,6 +2197,27 @@ export function ArchivesPage() {
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: api.getUsers,
|
||||
enabled: viewMode === 'log',
|
||||
});
|
||||
|
||||
const { data: printLogData, isLoading: isLogLoading } = useQuery({
|
||||
queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
|
||||
queryFn: () => api.getPrintLog({
|
||||
search: search || undefined,
|
||||
printerId: filterPrinter || undefined,
|
||||
username: logFilterUser || undefined,
|
||||
status: logFilterStatus || undefined,
|
||||
dateFrom: logFilterDateFrom || undefined,
|
||||
dateTo: logFilterDateTo || undefined,
|
||||
limit: logPageSize,
|
||||
offset: logOffset,
|
||||
}),
|
||||
enabled: viewMode === 'log',
|
||||
});
|
||||
|
||||
const timeFormat: TimeFormat = settings?.time_format || 'system';
|
||||
const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
|
||||
|
||||
@@ -2191,6 +2236,18 @@ export function ArchivesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const clearLogMutation = useMutation({
|
||||
mutationFn: () => api.clearPrintLog(),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['print-log'] });
|
||||
setLogOffset(0);
|
||||
showToast(t('archives.log.cleared', { count: data.deleted }));
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t('archives.log.clearFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Persist all filters to localStorage
|
||||
useEffect(() => {
|
||||
if (filterPrinter !== null) {
|
||||
@@ -2248,6 +2305,47 @@ export function ArchivesPage() {
|
||||
localStorage.setItem('archiveCollection', collection);
|
||||
}, [collection]);
|
||||
|
||||
// Persist log view filters
|
||||
useEffect(() => {
|
||||
if (logFilterUser) {
|
||||
localStorage.setItem('logFilterUser', logFilterUser);
|
||||
} else {
|
||||
localStorage.removeItem('logFilterUser');
|
||||
}
|
||||
}, [logFilterUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logFilterStatus) {
|
||||
localStorage.setItem('logFilterStatus', logFilterStatus);
|
||||
} else {
|
||||
localStorage.removeItem('logFilterStatus');
|
||||
}
|
||||
}, [logFilterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logFilterDateFrom) {
|
||||
localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
|
||||
} else {
|
||||
localStorage.removeItem('logFilterDateFrom');
|
||||
}
|
||||
}, [logFilterDateFrom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logFilterDateTo) {
|
||||
localStorage.setItem('logFilterDateTo', logFilterDateTo);
|
||||
} else {
|
||||
localStorage.removeItem('logFilterDateTo');
|
||||
}
|
||||
}, [logFilterDateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('logOffset', logOffset.toString());
|
||||
}, [logOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('logPageSize', logPageSize.toString());
|
||||
}, [logPageSize]);
|
||||
|
||||
const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
|
||||
|
||||
// Extract unique materials and colors from archives
|
||||
@@ -2672,8 +2770,40 @@ export function ArchivesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
{/* View mode toggle — always visible */}
|
||||
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('archives.gridView')}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('archives.listView')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
title={t('archives.calendarView')}
|
||||
>
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('log')}
|
||||
title={t('archives.logView')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters (hidden in log view which has its own filters) */}
|
||||
{viewMode !== 'log' && <Card className="mb-6">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
|
||||
{/* Search - full width on mobile */}
|
||||
@@ -2799,29 +2929,6 @@ export function ArchivesPage() {
|
||||
<option value="size-asc">{t('archives.sortSmallest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title={t('archives.gridView')}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('archives.listView')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
title={t('archives.calendarView')}
|
||||
>
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hasTopFilters && (
|
||||
<Button
|
||||
@@ -2879,7 +2986,7 @@ export function ArchivesPage() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
|
||||
{/* Pending Uploads Panel (visible when in queue mode with pending files) */}
|
||||
<PendingUploadsPanel />
|
||||
@@ -2958,6 +3065,211 @@ export function ArchivesPage() {
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : viewMode === 'log' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Log filters */}
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative md:min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('archives.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
|
||||
/>
|
||||
</div>
|
||||
{/* Printer filter */}
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={filterPrinter || ''}
|
||||
onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
|
||||
>
|
||||
<option value="">{t('archives.log.allPrinters')}</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* User filter */}
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={logFilterUser || ''}
|
||||
onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
|
||||
>
|
||||
<option value="">{t('archives.log.allUsers')}</option>
|
||||
{users?.map((u) => (
|
||||
<option key={u.id} value={u.username}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Status filter */}
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={logFilterStatus || ''}
|
||||
onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
|
||||
>
|
||||
<option value="">{t('archives.log.allStatuses')}</option>
|
||||
<option value="completed">{t('archives.status.completed')}</option>
|
||||
<option value="failed">{t('archives.status.failed')}</option>
|
||||
<option value="stopped">{t('archives.status.stopped')}</option>
|
||||
<option value="cancelled">{t('archives.log.cancelled')}</option>
|
||||
<option value="skipped">{t('archives.log.skipped')}</option>
|
||||
</select>
|
||||
{/* Date range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={logFilterDateFrom}
|
||||
onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
|
||||
value={logFilterDateTo}
|
||||
onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
|
||||
/>
|
||||
</div>
|
||||
{/* Clear log button */}
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowClearLogConfirm(true)}
|
||||
disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('archives.log.clearLog')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Log table */}
|
||||
<Card>
|
||||
{isLogLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
) : !printLogData?.items.length ? (
|
||||
<div className="text-center py-12 text-bambu-gray">
|
||||
{t('archives.log.noEntries')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
|
||||
<th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-bambu-dark-tertiary">
|
||||
{printLogData.items.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
|
||||
<td className="px-4 py-3 text-white whitespace-nowrap">
|
||||
{formatDateTime(entry.started_at || entry.created_at, timeFormat)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.thumbnail_path && (
|
||||
<img
|
||||
src={api.getPrintLogThumbnail(entry.id)}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white truncate max-w-[200px]">
|
||||
{entry.print_name || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||
entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
|
||||
entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
|
||||
entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
|
||||
'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
|
||||
{entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{entry.filament_color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
|
||||
style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-bambu-gray-light text-xs">
|
||||
{entry.filament_type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
|
||||
<select
|
||||
className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
|
||||
value={logPageSize}
|
||||
onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Upload Modal */}
|
||||
@@ -3019,6 +3331,21 @@ export function ArchivesPage() {
|
||||
{showTagManagement && (
|
||||
<TagManagementModal onClose={() => setShowTagManagement(false)} />
|
||||
)}
|
||||
|
||||
{/* Clear Log Confirmation */}
|
||||
{showClearLogConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('archives.log.clearLogTitle')}
|
||||
message={t('archives.log.clearLogConfirm')}
|
||||
confirmText={t('archives.log.clearLogButton')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
clearLogMutation.mutate();
|
||||
setShowClearLogConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowClearLogConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user