mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Added full i8n localization support. Available languages are English and German
This commit is contained in:
@@ -131,7 +131,7 @@ describe('ConfirmModal', () => {
|
||||
|
||||
it('shows default loading text when loadingText not provided', () => {
|
||||
render(<ConfirmModal {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText('Processing...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables buttons when loading', () => {
|
||||
|
||||
@@ -78,7 +78,8 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
|
||||
// Look for the title in h2 element
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,13 +128,13 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +161,7 @@ describe('LinkSpoolModal', () => {
|
||||
expect(screen.getByText('PLA Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const linkButton = screen.getByRole('button', { name: /link spool/i });
|
||||
const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
|
||||
expect(linkButton).toBeDisabled();
|
||||
|
||||
// Select a spool
|
||||
@@ -182,7 +183,7 @@ describe('LinkSpoolModal', () => {
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
|
||||
// Click link button
|
||||
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
|
||||
@@ -197,7 +198,7 @@ describe('LinkSpoolModal', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
@@ -215,7 +216,7 @@ describe('LinkSpoolModal', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
@@ -233,7 +234,7 @@ describe('LinkSpoolModal', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('PLA Red'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
@@ -260,7 +261,7 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the backdrop (the element with bg-black/60)
|
||||
@@ -275,7 +276,7 @@ describe('LinkSpoolModal', () => {
|
||||
render(<LinkSpoolModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the X button in the header
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import en from '../../i18n/locales/en';
|
||||
import de from '../../i18n/locales/de';
|
||||
|
||||
/**
|
||||
* Recursively extracts all keys from a nested object as dot-notation paths.
|
||||
* Example: { foo: { bar: 'baz' } } => ['foo.bar']
|
||||
*/
|
||||
const getKeys = (obj: object, prefix = ''): string[] => {
|
||||
return Object.entries(obj).flatMap(([key, value]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
return typeof value === 'object' && value !== null
|
||||
? getKeys(value, path)
|
||||
: [path];
|
||||
});
|
||||
};
|
||||
|
||||
describe('i18n locale parity', () => {
|
||||
const enKeys = new Set(getKeys(en));
|
||||
const deKeys = new Set(getKeys(de));
|
||||
|
||||
it('German locale has all English keys', () => {
|
||||
const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
|
||||
expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
|
||||
});
|
||||
|
||||
it('English locale has all German keys', () => {
|
||||
const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
|
||||
expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
|
||||
});
|
||||
|
||||
it('both locales have the same number of keys', () => {
|
||||
expect(enKeys.size).toBe(deKeys.size);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||
import { ToastProvider } from '../../contexts/ToastContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
// Mock navigator.sendBeacon which isn't available in jsdom
|
||||
vi.stubGlobal('navigator', {
|
||||
@@ -39,15 +41,17 @@ function renderCameraPage(printerId: number) {
|
||||
|
||||
return rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/cameras/:printerId" element={<CameraPage />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/cameras/:printerId" element={<CameraPage />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -94,8 +98,9 @@ describe('CameraPage', () => {
|
||||
renderCameraPage(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Snapshot')).toBeInTheDocument();
|
||||
// Check for translation key or translated text
|
||||
expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +129,8 @@ describe('CameraPage', () => {
|
||||
renderCameraPage(0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
|
||||
// Check for translation key or translated text
|
||||
expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
@@ -18,14 +19,18 @@ interface ConfirmModalProps {
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'default',
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedConfirmText = confirmText ?? t('common.confirm');
|
||||
const resolvedCancelText = cancelText ?? t('common.cancel');
|
||||
const resolvedLoadingText = loadingText ?? t('common.loading');
|
||||
// Close on Escape key (but not while loading)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -70,7 +75,7 @@ export function ConfirmModal({
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
|
||||
{cancelText}
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
@@ -80,10 +85,10 @@ export function ConfirmModal({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{loadingText || 'Processing...'}
|
||||
{resolvedLoadingText}
|
||||
</>
|
||||
) : (
|
||||
confirmText
|
||||
resolvedConfirmText
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { Archive } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
const FAILURE_REASONS = [
|
||||
'Adhesion failure',
|
||||
'Spaghetti / Detached',
|
||||
'Layer shift',
|
||||
'Clogged nozzle',
|
||||
'Filament runout',
|
||||
'Warping',
|
||||
'Stringing',
|
||||
'Under-extrusion',
|
||||
'Power failure',
|
||||
'User cancelled',
|
||||
'Other',
|
||||
];
|
||||
// Keys for failure reasons - translated at render time
|
||||
const FAILURE_REASON_KEYS = [
|
||||
'adhesionFailure',
|
||||
'spaghettiDetached',
|
||||
'layerShift',
|
||||
'cloggedNozzle',
|
||||
'filamentRunout',
|
||||
'warping',
|
||||
'stringing',
|
||||
'underExtrusion',
|
||||
'powerFailure',
|
||||
'userCancelled',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
const ARCHIVE_STATUSES = [
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'aborted', label: 'Cancelled' },
|
||||
{ value: 'printing', label: 'Printing' },
|
||||
];
|
||||
// Keys for archive statuses - translated at render time
|
||||
const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
|
||||
|
||||
interface EditArchiveModalProps {
|
||||
archive: Archive;
|
||||
@@ -33,6 +31,8 @@ interface EditArchiveModalProps {
|
||||
}
|
||||
|
||||
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-white">Edit Archive</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
@@ -218,25 +218,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Print Name */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Name</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={printName}
|
||||
onChange={(e) => setPrintName(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"
|
||||
placeholder="Print name"
|
||||
placeholder={t('editArchive.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Printer */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
|
||||
<select
|
||||
value={printerId ?? ''}
|
||||
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
|
||||
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="">No printer</option>
|
||||
<option value="">{t('editArchive.noPrinter')}</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
@@ -249,14 +249,14 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<FolderKanban className="w-4 h-4 inline mr-1" />
|
||||
Project
|
||||
{t('editArchive.project')}
|
||||
</label>
|
||||
<select
|
||||
value={projectId ?? ''}
|
||||
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
|
||||
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="">No project</option>
|
||||
<option value="">{t('editArchive.noProject')}</option>
|
||||
{projects?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
@@ -269,7 +269,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Hash className="w-4 h-4 inline mr-1" />
|
||||
Items Printed
|
||||
{t('editArchive.itemsPrinted')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -280,19 +280,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
placeholder="1"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Number of items produced in this print job
|
||||
{t('editArchive.itemsPrintedHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder="Add notes about this print..."
|
||||
placeholder={t('editArchive.notesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +300,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Link className="w-4 h-4 inline mr-1" />
|
||||
External Link
|
||||
{t('editArchive.externalLink')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -310,13 +310,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
placeholder="https://printables.com/model/..."
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Link to Printables, Thingiverse, or other source
|
||||
{t('editArchive.externalLinkHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
|
||||
{/* Current tags as chips */}
|
||||
{currentTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
@@ -355,13 +355,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
|
||||
}}
|
||||
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"
|
||||
placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
|
||||
placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
|
||||
/>
|
||||
{/* Suggestions dropdown */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
|
||||
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
|
||||
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
|
||||
{currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
|
||||
</div>
|
||||
<div className="p-2 flex flex-wrap gap-1.5">
|
||||
{tagSuggestions.map((tag) => (
|
||||
@@ -382,7 +382,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Status</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
@@ -394,9 +394,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{ARCHIVE_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
{ARCHIVE_STATUS_KEYS.map((statusKey) => (
|
||||
<option key={statusKey} value={statusKey}>
|
||||
{t(`editArchive.statuses.${statusKey}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -405,16 +405,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
{/* Failure Reason - only show for failed/aborted prints */}
|
||||
{(status === 'failed' || status === 'aborted') && (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
|
||||
<select
|
||||
value={failureReason}
|
||||
onChange={(e) => setFailureReason(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="">Select reason...</option>
|
||||
{FAILURE_REASONS.map((reason) => (
|
||||
<option key={reason} value={reason}>
|
||||
{reason}
|
||||
<option value="">{t('editArchive.selectReason')}</option>
|
||||
{FAILURE_REASON_KEYS.map((reasonKey) => (
|
||||
<option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
|
||||
{t(`editArchive.failureReasons.${reasonKey}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -425,7 +425,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Camera className="w-4 h-4 inline mr-1" />
|
||||
Photos of Printed Result
|
||||
{t('editArchive.photos')}
|
||||
</label>
|
||||
{/* Photo grid */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
@@ -433,7 +433,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
<div key={filename} className="relative group">
|
||||
<img
|
||||
src={api.getArchivePhotoUrl(archive.id, filename)}
|
||||
alt="Print result"
|
||||
alt={t('editArchive.printResult')}
|
||||
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
|
||||
/>
|
||||
<button
|
||||
@@ -462,7 +462,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
|
||||
<p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -473,7 +473,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -481,7 +481,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
|
||||
className="flex-1"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface FilamentData {
|
||||
@@ -38,6 +39,7 @@ interface FilamentHoverCardProps {
|
||||
* Replaces the basic browser tooltip with a styled popover.
|
||||
*/
|
||||
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState<'top' | 'bottom'>('top');
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -201,7 +203,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{/* Profile name */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
|
||||
Profile
|
||||
{t('ams.profile')}
|
||||
</span>
|
||||
<span className="text-xs text-white font-semibold truncate max-w-[120px]">
|
||||
{data.profile}
|
||||
@@ -211,7 +213,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{/* K Factor */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
|
||||
K Factor
|
||||
{t('ams.kFactor')}
|
||||
</span>
|
||||
<span className="text-xs text-bambu-green font-mono font-bold">
|
||||
{data.kFactor}
|
||||
@@ -223,7 +225,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
|
||||
<Droplets className="w-3 h-3" />
|
||||
Fill
|
||||
{t('ams.fill')}
|
||||
</span>
|
||||
<span className="text-xs text-white font-semibold">
|
||||
{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
|
||||
@@ -251,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{/* Tray UUID with copy button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
|
||||
Spool ID
|
||||
{t('spoolman.spoolId')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -280,10 +282,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
|
||||
title="View this spool in Spoolman"
|
||||
title={t('spoolman.openInSpoolman')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Open in Spoolman
|
||||
{t('spoolman.openInSpoolman')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -302,10 +304,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
|
||||
: 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
|
||||
}`}
|
||||
title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
|
||||
title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
Link to Spoolman
|
||||
{t('spoolman.linkToSpoolman')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -320,10 +322,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
configureSlot.onConfigure?.();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
|
||||
title="Configure slot with filament profile and K value"
|
||||
title={t('ams.configureSlot')}
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure
|
||||
{t('ams.configure')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -357,6 +359,7 @@ interface EmptySlotHoverCardProps {
|
||||
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
|
||||
*/
|
||||
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -394,7 +397,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
|
||||
rounded-md shadow-lg overflow-hidden
|
||||
">
|
||||
<div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
|
||||
Empty slot
|
||||
{t('ams.emptySlot')}
|
||||
</div>
|
||||
{/* Configure slot button */}
|
||||
{configureSlot?.enabled && (
|
||||
@@ -405,10 +408,10 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
|
||||
configureSlot.onConfigure?.();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
|
||||
title="Configure slot with filament profile and K value"
|
||||
title={t('ams.configureSlot')}
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
Configure
|
||||
{t('ams.configure')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
Folder,
|
||||
@@ -82,6 +83,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
];
|
||||
|
||||
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
@@ -119,13 +121,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
|
||||
showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
|
||||
queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
|
||||
setSelectedFiles(new Set());
|
||||
setFilesToDelete([]);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Delete failed: ${error.message}`, 'error');
|
||||
showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -227,7 +229,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-5 h-5 text-bambu-green" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">File Manager</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('printerFiles.title')}</h2>
|
||||
<p className="text-sm text-bambu-gray">{printerName}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,13 +238,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
{storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
|
||||
<div className="text-sm text-bambu-gray flex items-center gap-2">
|
||||
{storageData.used_bytes != null && (
|
||||
<span>Used: {formatStorageSize(storageData.used_bytes)}</span>
|
||||
<span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
|
||||
)}
|
||||
{storageData.used_bytes != null && storageData.free_bytes != null && (
|
||||
<span className="text-bambu-dark-tertiary">|</span>
|
||||
)}
|
||||
{storageData.free_bytes != null && (
|
||||
<span>Free: {formatStorageSize(storageData.free_bytes)}</span>
|
||||
<span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -278,7 +280,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter files..."
|
||||
placeholder={t('printerFiles.filterPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
@@ -481,7 +483,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
|
||||
{t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,13 +492,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
|
||||
{/* Delete Confirmation Modal */}
|
||||
{filesToDelete.length > 0 && (
|
||||
<ConfirmModal
|
||||
title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
|
||||
title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}
|
||||
message={
|
||||
filesToDelete.length > 1
|
||||
? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
|
||||
: `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
|
||||
? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })
|
||||
: t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })
|
||||
}
|
||||
confirmText="Delete"
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(filesToDelete);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// HMS Error Modal - Comprehensive error code database
|
||||
// Source: https://github.com/greghesp/ha-bambulab
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
|
||||
import type { HMSError } from '../api/client';
|
||||
|
||||
@@ -904,6 +905,8 @@ function getHMSHomeUrl(): string {
|
||||
}
|
||||
|
||||
export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Debug: log errors to see what data we're receiving
|
||||
console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
|
||||
|
||||
@@ -930,7 +933,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||
<h2 className="text-lg font-semibold text-white">Errors - {printerName}</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -945,7 +948,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
|
||||
{knownErrors.length === 0 ? (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No errors</p>
|
||||
<p>{t('hmsErrors.noErrors')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -979,7 +982,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
|
||||
className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Bambu Lab Wiki
|
||||
{t('hmsErrors.viewOnWiki')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -993,7 +996,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Clear errors on the printer to dismiss them here.
|
||||
{t('hmsErrors.clearInstructions')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Gauge,
|
||||
Loader2,
|
||||
@@ -171,6 +172,7 @@ function KProfileModal({
|
||||
onSaveNote,
|
||||
hasPermission,
|
||||
}: KProfileModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [name, setName] = useState(profile?.name || '');
|
||||
@@ -219,7 +221,7 @@ function KProfileModal({
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
console.log('[KProfile] Save success:', result);
|
||||
showToast('K-profile saved');
|
||||
showToast(t('kProfiles.toast.profileSaved'));
|
||||
// Save note if it changed (including clearing it)
|
||||
if (onSaveNote && note !== initialNote) {
|
||||
let profileKey: string;
|
||||
@@ -258,7 +260,7 @@ function KProfileModal({
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
console.log('[KProfile] Delete success:', result);
|
||||
showToast('K-profile deleted');
|
||||
showToast(t('kProfiles.toast.profileDeleted'));
|
||||
// Show syncing indicator while printer processes the command
|
||||
setIsSyncing(true);
|
||||
// Add longer delay for delete - printer needs more time to process
|
||||
@@ -294,7 +296,7 @@ function KProfileModal({
|
||||
|
||||
// Validate at least one extruder is selected for dual-nozzle
|
||||
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
|
||||
showToast('Please select at least one extruder', 'error');
|
||||
showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,7 +342,7 @@ function KProfileModal({
|
||||
|
||||
try {
|
||||
await api.setKProfilesBatch(printerId, batchPayload);
|
||||
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
|
||||
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
|
||||
// Save note for new batch profiles
|
||||
if (onSaveNote && note) {
|
||||
const profileKey = `name_${name}_${filamentId}`;
|
||||
@@ -348,7 +350,7 @@ function KProfileModal({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KProfile] Failed to save batch:', error);
|
||||
showToast('Failed to save K-profiles', 'error');
|
||||
showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
|
||||
setIsSyncing(false);
|
||||
setSavingProgress({ current: 0, total: 0 });
|
||||
return;
|
||||
@@ -373,16 +375,16 @@ function KProfileModal({
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
|
||||
<p className="text-white font-medium">
|
||||
{savingProgress.total > 1
|
||||
? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
|
||||
: 'Syncing with printer...'}
|
||||
? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
|
||||
: t('kProfiles.modal.syncing')}
|
||||
</p>
|
||||
<p className="text-bambu-gray text-sm mt-1">Please wait</p>
|
||||
<p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
|
||||
{profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -396,21 +398,21 @@ function KProfileModal({
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
{/* Profile Name - read-only when editing */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!!profile}
|
||||
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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
placeholder="My PLA Profile"
|
||||
placeholder={t('kProfiles.modal.profileNamePlaceholder')}
|
||||
required={!profile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* K-Value - always editable */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -430,17 +432,17 @@ function KProfileModal({
|
||||
}
|
||||
}}
|
||||
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 font-mono"
|
||||
placeholder="0.020"
|
||||
placeholder={t('kProfiles.modal.kValuePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
|
||||
{t('kProfiles.modal.kValueHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filament - read-only when editing */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
|
||||
<select
|
||||
value={filamentId}
|
||||
onChange={(e) => {
|
||||
@@ -460,7 +462,7 @@ function KProfileModal({
|
||||
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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
required={!profile}
|
||||
>
|
||||
<option value="">Select filament...</option>
|
||||
<option value="">{t('kProfiles.modal.selectFilament')}</option>
|
||||
{/* Show current filament when editing - look up from knownFilaments */}
|
||||
{profile?.filament_id && (
|
||||
<option key={profile.filament_id} value={profile.filament_id}>
|
||||
@@ -476,7 +478,7 @@ function KProfileModal({
|
||||
</select>
|
||||
{!profile && knownFilaments.length === 0 && (
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
No filaments found. Create a K-profile in Bambu Studio first.
|
||||
{t('kProfiles.modal.noFilamentsHelp')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -484,7 +486,7 @@ function KProfileModal({
|
||||
{/* Flow Type and Nozzle Size - read-only when editing */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
||||
<select
|
||||
value={nozzleType}
|
||||
onChange={(e) => {
|
||||
@@ -503,12 +505,12 @@ function KProfileModal({
|
||||
disabled={!!profile}
|
||||
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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="HH00">High Flow</option>
|
||||
<option value="HS00">Standard</option>
|
||||
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value="HS00">{t('kProfiles.modal.standard')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
|
||||
<select
|
||||
value={modalDiameter}
|
||||
onChange={(e) => setModalDiameter(e.target.value)}
|
||||
@@ -527,12 +529,12 @@ function KProfileModal({
|
||||
{isDualNozzle && (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
{profile ? 'Extruder' : 'Extruders'}
|
||||
{profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
|
||||
</label>
|
||||
{profile ? (
|
||||
// Read-only display for editing
|
||||
<div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
|
||||
{profile.extruder_id === 1 ? 'Left' : 'Right'}
|
||||
{profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
|
||||
</div>
|
||||
) : (
|
||||
// Checkboxes for new profile - can select both
|
||||
@@ -550,7 +552,7 @@ function KProfileModal({
|
||||
}}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
|
||||
/>
|
||||
<span className="text-white">Left</span>
|
||||
<span className="text-white">{t('kProfiles.modal.left')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -565,7 +567,7 @@ function KProfileModal({
|
||||
}}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
|
||||
/>
|
||||
<span className="text-white">Right</span>
|
||||
<span className="text-white">{t('kProfiles.modal.right')}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -574,16 +576,16 @@ function KProfileModal({
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add notes about this profile..."
|
||||
placeholder={t('kProfiles.modal.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Notes are saved in Bambuddy, not on the printer
|
||||
{t('kProfiles.modal.notesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -594,7 +596,7 @@ function KProfileModal({
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
|
||||
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
|
||||
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
|
||||
className="text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
@@ -611,12 +613,12 @@ function KProfileModal({
|
||||
disabled={isSyncing}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
|
||||
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
|
||||
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
|
||||
className="flex-1"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
@@ -624,7 +626,7 @@ function KProfileModal({
|
||||
) : (
|
||||
<Gauge className="w-4 h-4" />
|
||||
)}
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -641,12 +643,12 @@ function KProfileModal({
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
|
||||
<p className="text-sm text-bambu-gray">This cannot be undone</p>
|
||||
<h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
|
||||
<p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-bambu-gray mb-6">
|
||||
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
|
||||
{t('kProfiles.deleteConfirm.message', { name: profile?.name })}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
@@ -654,7 +656,7 @@ function KProfileModal({
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -669,7 +671,7 @@ function KProfileModal({
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -691,6 +693,7 @@ const STORAGE_KEYS = {
|
||||
};
|
||||
|
||||
export function KProfilesView() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
|
||||
@@ -882,7 +885,7 @@ export function KProfilesView() {
|
||||
// Export profiles to JSON file
|
||||
const handleExport = useCallback(() => {
|
||||
if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
|
||||
showToast('No profiles to export', 'error');
|
||||
showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -910,8 +913,8 @@ export function KProfilesView() {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Exported ${kprofiles.profiles.length} profiles`);
|
||||
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
|
||||
showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
|
||||
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
|
||||
|
||||
// Import profiles from JSON file
|
||||
const handleImport = useCallback(() => {
|
||||
@@ -927,7 +930,7 @@ export function KProfilesView() {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!data.profiles || !Array.isArray(data.profiles)) {
|
||||
showToast('Invalid file format', 'error');
|
||||
showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -954,15 +957,15 @@ export function KProfilesView() {
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
|
||||
showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
|
||||
refetchProfiles();
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
showToast('Failed to parse import file', 'error');
|
||||
showToast(t('kProfiles.toast.failedToParseImport'), 'error');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
|
||||
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
|
||||
|
||||
// Toggle profile selection using composite key
|
||||
const toggleProfileSelection = useCallback((profileKey: string) => {
|
||||
@@ -1012,13 +1015,13 @@ export function KProfilesView() {
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Deleted ${deleted} profiles`);
|
||||
showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
|
||||
setBulkDeleteInProgress(false);
|
||||
setShowBulkDeleteConfirm(false);
|
||||
setSelectionMode(false);
|
||||
setSelectedProfiles(new Set());
|
||||
refetchProfiles();
|
||||
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
|
||||
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
|
||||
|
||||
// Generate possible keys for a profile (for notes lookup)
|
||||
// Returns array of keys to check: setting_id, slot-based, name-based
|
||||
@@ -1042,9 +1045,9 @@ export function KProfilesView() {
|
||||
refetchNotes();
|
||||
} catch (err) {
|
||||
console.error('Failed to save note:', err);
|
||||
showToast('Failed to save note', 'error');
|
||||
showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
|
||||
}
|
||||
}, [selectedPrinter, refetchNotes, showToast]);
|
||||
}, [selectedPrinter, refetchNotes, showToast, t]);
|
||||
|
||||
// Get note for a profile (checks all possible keys)
|
||||
// Returns { note, key } so we know which key the note was stored under
|
||||
@@ -1077,9 +1080,9 @@ export function KProfilesView() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
|
||||
<p className="text-bambu-gray">
|
||||
Add a printer in Settings to manage K-profiles
|
||||
{t('kProfiles.addPrinterInSettings')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1091,9 +1094,9 @@ export function KProfilesView() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
|
||||
<p className="text-bambu-gray">
|
||||
Enable a printer connection to view its K-profiles
|
||||
{t('kProfiles.enablePrinterConnection')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1106,14 +1109,14 @@ export function KProfilesView() {
|
||||
{isFetching && !kprofilesLoading && (
|
||||
<div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
|
||||
<Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
|
||||
<p className="text-white font-medium">Loading K-Profiles...</p>
|
||||
<p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Printer & Nozzle Selector */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
|
||||
<select
|
||||
value={selectedPrinter || ''}
|
||||
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
|
||||
@@ -1128,7 +1131,7 @@ export function KProfilesView() {
|
||||
</div>
|
||||
|
||||
<div className="w-32">
|
||||
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
|
||||
<select
|
||||
value={nozzleDiameter}
|
||||
onChange={(e) => setNozzleDiameter(e.target.value)}
|
||||
@@ -1146,18 +1149,18 @@ export function KProfilesView() {
|
||||
variant="secondary"
|
||||
onClick={() => refetchProfiles()}
|
||||
disabled={isFetching || !hasPermission('kprofiles:read')}
|
||||
title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
|
||||
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
{t('kProfiles.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={!hasPermission('kprofiles:create')}
|
||||
title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
|
||||
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Profile
|
||||
{t('kProfiles.addProfile')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1170,7 +1173,7 @@ export function KProfilesView() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by name or filament..."
|
||||
placeholder={t('kProfiles.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1181,9 +1184,9 @@ export function KProfilesView() {
|
||||
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
|
||||
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="all">All Extruders</option>
|
||||
<option value="left">Left Only</option>
|
||||
<option value="right">Right Only</option>
|
||||
<option value="all">{t('kProfiles.allExtruders')}</option>
|
||||
<option value="left">{t('kProfiles.leftOnly')}</option>
|
||||
<option value="right">{t('kProfiles.rightOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
@@ -1193,9 +1196,9 @@ export function KProfilesView() {
|
||||
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
||||
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="all">All Flow</option>
|
||||
<option value="hf">HF Only</option>
|
||||
<option value="s">S Only</option>
|
||||
<option value="all">{t('kProfiles.allFlow')}</option>
|
||||
<option value="hf">{t('kProfiles.hfOnly')}</option>
|
||||
<option value="s">{t('kProfiles.sOnly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
@@ -1204,9 +1207,9 @@ export function KProfilesView() {
|
||||
onChange={(e) => setSortOption(e.target.value as SortOption)}
|
||||
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="name">Sort: Name</option>
|
||||
<option value="k_value">Sort: K-Value</option>
|
||||
<option value="filament">Sort: Filament</option>
|
||||
<option value="name">{t('kProfiles.sortName')}</option>
|
||||
<option value="k_value">{t('kProfiles.sortKValue')}</option>
|
||||
<option value="filament">{t('kProfiles.sortFilament')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1217,19 +1220,19 @@ export function KProfilesView() {
|
||||
variant="secondary"
|
||||
onClick={handleExport}
|
||||
disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
|
||||
title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
|
||||
title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
{t('kProfiles.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleImport}
|
||||
disabled={!hasPermission('kprofiles:create')}
|
||||
title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
|
||||
title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import
|
||||
{t('kProfiles.import')}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{selectionMode ? (
|
||||
@@ -1237,20 +1240,19 @@ export function KProfilesView() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={selectAllProfiles}
|
||||
title="Select all visible profiles"
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
Select All
|
||||
{t('kProfiles.selectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
|
||||
className="text-red-500 hover:bg-red-500/10"
|
||||
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
|
||||
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete ({selectedProfiles.size})
|
||||
{t('kProfiles.delete')} ({selectedProfiles.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -1260,7 +1262,7 @@ export function KProfilesView() {
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -1268,10 +1270,10 @@ export function KProfilesView() {
|
||||
variant="secondary"
|
||||
onClick={() => setSelectionMode(true)}
|
||||
disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
|
||||
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
|
||||
title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
Select
|
||||
{t('kProfiles.select')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1285,13 +1287,13 @@ export function KProfilesView() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
|
||||
<p className="text-bambu-gray mb-4">
|
||||
The selected printer is not connected. Power it on to view K-profiles.
|
||||
{t('kProfiles.printerOfflineDesc')}
|
||||
</p>
|
||||
<Button variant="secondary" onClick={() => refetchProfiles()}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1301,7 +1303,7 @@ export function KProfilesView() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left Extruder (extruder_id 1 on Bambu) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
|
||||
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
|
||||
<div className="space-y-1">
|
||||
{filteredProfiles
|
||||
.filter((p) => p.extruder_id === 1)
|
||||
@@ -1321,7 +1323,7 @@ export function KProfilesView() {
|
||||
</div>
|
||||
{/* Right Extruder (extruder_id 0 on Bambu) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
|
||||
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
|
||||
<div className="space-y-1">
|
||||
{filteredProfiles
|
||||
.filter((p) => p.extruder_id === 0)
|
||||
@@ -1361,9 +1363,9 @@ export function KProfilesView() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
|
||||
<p className="text-bambu-gray">
|
||||
No profiles match your search criteria
|
||||
{t('kProfiles.noMatchingProfilesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1371,13 +1373,13 @@ export function KProfilesView() {
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
|
||||
<p className="text-bambu-gray mb-4">
|
||||
No pressure advance profiles found for {nozzleDiameter}mm nozzle
|
||||
{t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
|
||||
</p>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create First Profile
|
||||
{t('kProfiles.createFirstProfile')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1466,12 +1468,12 @@ export function KProfilesView() {
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
|
||||
<p className="text-sm text-bambu-gray">This cannot be undone</p>
|
||||
<h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
|
||||
<p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-bambu-gray mb-6">
|
||||
Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
|
||||
{t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
@@ -1480,7 +1482,7 @@ export function KProfilesView() {
|
||||
disabled={bulkDeleteInProgress}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={executeBulkDelete}
|
||||
@@ -1492,7 +1494,7 @@ export function KProfilesView() {
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -619,7 +619,7 @@ export function Layout() {
|
||||
<button
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="Change Password"
|
||||
title={t('changePassword.title')}
|
||||
>
|
||||
<Key className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -723,7 +723,7 @@ export function Layout() {
|
||||
<button
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="Change Password"
|
||||
title={t('changePassword.title')}
|
||||
>
|
||||
<Key className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -824,20 +824,19 @@ export function Layout() {
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-yellow-400 mb-2">
|
||||
Print Paused!
|
||||
{t('plateAlert.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-white mb-2">
|
||||
{plateDetectionAlert.printer_name}
|
||||
</p>
|
||||
<p className="text-bambu-gray mb-6">
|
||||
Objects detected on build plate. The print has been automatically paused.
|
||||
Please clear the plate and resume the print.
|
||||
{t('plateAlert.message')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setPlateDetectionAlert(null)}
|
||||
className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
I Understand
|
||||
{t('plateAlert.understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -861,7 +860,7 @@ export function Layout() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Change Password</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -879,34 +878,34 @@ export function Layout() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Current Password
|
||||
{t('changePassword.currentPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={changePasswordData.currentPassword}
|
||||
onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter current password"
|
||||
placeholder={t('changePassword.currentPasswordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
New Password
|
||||
{t('changePassword.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={changePasswordData.newPassword}
|
||||
onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter new password (min 6 characters)"
|
||||
placeholder={t('changePassword.newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Confirm New Password
|
||||
{t('changePassword.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -917,12 +916,12 @@ export function Layout() {
|
||||
? 'border-red-500'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
placeholder="Confirm new password"
|
||||
placeholder={t('changePassword.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
{changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
|
||||
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
|
||||
<p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -934,26 +933,26 @@ export function Layout() {
|
||||
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
showToast(t('changePassword.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (changePasswordData.newPassword.length < 6) {
|
||||
showToast('Password must be at least 6 characters', 'error');
|
||||
showToast(t('changePassword.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
setChangePasswordLoading(true);
|
||||
try {
|
||||
await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
|
||||
showToast('Password changed successfully', 'success');
|
||||
showToast(t('changePassword.success'), 'success');
|
||||
setShowChangePasswordModal(false);
|
||||
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to change password';
|
||||
const message = error instanceof Error ? error.message : t('changePassword.failed');
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setChangePasswordLoading(false);
|
||||
@@ -964,12 +963,12 @@ export function Layout() {
|
||||
{changePasswordLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Changing...
|
||||
{t('changePassword.changing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4" />
|
||||
Change Password
|
||||
{t('changePassword.title')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Link2, Check } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -17,6 +18,7 @@ interface LinkSpoolModalProps {
|
||||
}
|
||||
|
||||
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
|
||||
@@ -35,11 +37,11 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
|
||||
showToast('Spool linked to Spoolman successfully', 'success');
|
||||
showToast(t('spoolman.linkSuccess'), 'success');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to link spool: ${error.message}`, 'error');
|
||||
showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -96,14 +98,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
|
||||
{/* Spool UUID */}
|
||||
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
|
||||
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
|
||||
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
|
||||
</div>
|
||||
|
||||
{/* Spool list */}
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray mb-2">
|
||||
Select a Spoolman spool to link:
|
||||
{t('spoolman.selectSpool')}:
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -148,8 +150,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>No unlinked spools found in Spoolman.</p>
|
||||
<p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
|
||||
<p>{t('spoolman.noUnlinkedSpools')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -158,7 +159,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLink}
|
||||
@@ -167,12 +168,12 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
|
||||
{linkMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Linking...
|
||||
{t('spoolman.syncing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="w-4 h-4" />
|
||||
Link Spool
|
||||
{t('spoolman.linkToSpoolman')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
|
||||
import { api, type MQTTLogEntry } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -11,6 +12,7 @@ interface MQTTDebugModalProps {
|
||||
}
|
||||
|
||||
export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
|
||||
@@ -119,7 +121,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('mqttDebug.title')}</h2>
|
||||
<p className="text-sm text-bambu-gray">{printerName}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -141,7 +143,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
Stop
|
||||
{t('mqttDebug.stopLogging')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -150,7 +152,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
disabled={enableMutation.isPending}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Logging
|
||||
{t('mqttDebug.startLogging')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -160,7 +162,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
disabled={clearMutation.isPending || logs.length === 0}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
{t('mqttDebug.clearLog')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -191,7 +193,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search topic or payload..."
|
||||
placeholder={t('mqttDebug.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
||||
@@ -214,7 +216,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
{t('mqttDebug.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirectionFilter('in')}
|
||||
@@ -225,7 +227,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
In
|
||||
{t('mqttDebug.incoming')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDirectionFilter('out')}
|
||||
@@ -236,7 +238,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
}`}
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
Out
|
||||
{t('mqttDebug.outgoing')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,15 +251,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
|
||||
<p className="mb-2">No messages logged yet</p>
|
||||
<p className="mb-2">{t('mqttDebug.noMessages')}</p>
|
||||
{!loggingEnabled && (
|
||||
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
|
||||
<p className="text-sm">{t('mqttDebug.startLoggingHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
|
||||
<p className="mb-2">No messages match your filter</p>
|
||||
<p className="text-sm">Try adjusting your search or filter criteria</p>
|
||||
<p className="mb-2">{t('mqttDebug.noMessagesMatch')}</p>
|
||||
<p className="text-sm">{t('mqttDebug.adjustFilterHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -281,7 +283,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
className={`shrink-0 ${
|
||||
isIncoming ? 'text-blue-400' : 'text-green-400'
|
||||
}`}
|
||||
title={isIncoming ? 'Incoming' : 'Outgoing'}
|
||||
title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}
|
||||
>
|
||||
{isIncoming ? (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
@@ -313,14 +315,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
||||
{loggingEnabled ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Logging active - messages will auto-refresh
|
||||
{t('mqttDebug.loggingActive')}
|
||||
</span>
|
||||
) : (
|
||||
<span>Logging stopped</span>
|
||||
<span>{t('mqttDebug.loggingStopped')}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
|
||||
@@ -41,6 +42,7 @@ export function PrintModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PrintModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -529,35 +531,35 @@ export function PrintModal({
|
||||
|
||||
if (mode === 'reprint') {
|
||||
return {
|
||||
title: isLibraryFile ? 'Print' : 'Re-print',
|
||||
title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
|
||||
icon: Printer,
|
||||
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
|
||||
submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
|
||||
submitIcon: Printer,
|
||||
loadingText: submitProgress.total > 1
|
||||
? `Sending ${submitProgress.current}/${submitProgress.total}...`
|
||||
: 'Sending...',
|
||||
? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
|
||||
: t('queue.sending'),
|
||||
};
|
||||
}
|
||||
if (mode === 'add-to-queue') {
|
||||
return {
|
||||
title: 'Schedule Print',
|
||||
title: t('queue.schedulePrint'),
|
||||
icon: Calendar,
|
||||
submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
|
||||
submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
|
||||
submitIcon: Calendar,
|
||||
loadingText: submitProgress.total > 1
|
||||
? `Adding ${submitProgress.current}/${submitProgress.total}...`
|
||||
: 'Adding...',
|
||||
? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
|
||||
: t('queue.adding'),
|
||||
};
|
||||
}
|
||||
// edit-queue-item mode
|
||||
return {
|
||||
title: 'Edit Queue Item',
|
||||
title: t('queue.editQueueItem'),
|
||||
icon: Pencil,
|
||||
submitText: 'Save',
|
||||
submitText: t('common.save'),
|
||||
submitIcon: Pencil,
|
||||
loadingText: submitProgress.total > 1
|
||||
? `Saving ${submitProgress.current}/${submitProgress.total}...`
|
||||
: 'Saving...',
|
||||
? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
|
||||
: t('common.saving'),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { BulkUploadResult } from '../api/client';
|
||||
@@ -20,6 +21,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -150,7 +152,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
<CardContent className="p-0 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
|
||||
<h2 className="text-xl font-semibold text-white">{t('uploadModal.title')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
@@ -173,15 +175,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
|
||||
<p className="text-white mb-2">
|
||||
Drag & drop .3mf files here
|
||||
{t('uploadModal.dragDrop')}
|
||||
</p>
|
||||
<p className="text-bambu-gray text-sm mb-4">or</p>
|
||||
<p className="text-bambu-gray text-sm mb-4">{t('uploadModal.or')}</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Browse Files
|
||||
{t('uploadModal.browseFiles')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -197,7 +199,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
{/* Info about printer model extraction */}
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-xs text-bambu-gray">
|
||||
The printer model will be automatically extracted from the 3MF file metadata.
|
||||
{t('uploadModal.extractionInfo')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -249,9 +251,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
<div className="px-4 pb-4">
|
||||
<div className="p-3 bg-bambu-dark rounded-lg">
|
||||
<p className="text-sm text-white">
|
||||
<span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
|
||||
<span className="text-bambu-green">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}
|
||||
{uploadResult.failed > 0 && (
|
||||
<>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
|
||||
<>, <span className="text-red-400">{uploadResult.failed}</span> {t('uploadModal.failed')}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -261,7 +263,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button variant="secondary" onClick={onClose} className="flex-1">
|
||||
{uploadResult ? 'Close' : 'Cancel'}
|
||||
{uploadResult ? t('common.close') : t('common.cancel')}
|
||||
</Button>
|
||||
{!uploadResult && (
|
||||
<Button
|
||||
@@ -272,12 +274,12 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
{t('uploadModal.uploading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {pendingCount > 0 && `(${pendingCount})`}
|
||||
{t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
+2112
-20
File diff suppressed because it is too large
Load Diff
+2111
-19
File diff suppressed because it is too large
Load Diff
+237
-227
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -10,6 +11,7 @@ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
||||
const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
|
||||
|
||||
export function CameraPage() {
|
||||
const { t } = useTranslation();
|
||||
const { printerId } = useParams<{ printerId: string }>();
|
||||
const id = parseInt(printerId || '0', 10);
|
||||
|
||||
@@ -539,7 +541,7 @@ export function CameraPage() {
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-white">Invalid printer ID</p>
|
||||
<p className="text-white">{t('camera.invalidPrinterId')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -564,7 +566,7 @@ export function CameraPage() {
|
||||
: 'text-bambu-gray hover:text-white disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
{t('camera.live')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchToMode('snapshot')}
|
||||
@@ -575,21 +577,21 @@ export function CameraPage() {
|
||||
: 'text-bambu-gray hover:text-white disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
Snapshot
|
||||
{t('camera.snapshot')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={isDisabled}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
|
||||
title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
|
||||
title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-4 h-4 text-bambu-gray" />
|
||||
@@ -618,7 +620,7 @@ export function CameraPage() {
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
|
||||
{streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -627,15 +629,15 @@ export function CameraPage() {
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
|
||||
<div className="text-center p-4">
|
||||
<WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
|
||||
<p className="text-white mb-2">Connection lost</p>
|
||||
<p className="text-white mb-2">{t('camera.connectionLost')}</p>
|
||||
<p className="text-sm text-bambu-gray mb-3">
|
||||
Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
|
||||
{t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
|
||||
</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
|
||||
>
|
||||
Reconnect now
|
||||
{t('camera.reconnectNow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -644,15 +646,15 @@ export function CameraPage() {
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
|
||||
<div className="text-center p-4">
|
||||
<AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
|
||||
<p className="text-white mb-2">Camera unavailable</p>
|
||||
<p className="text-white mb-2">{t('camera.cameraUnavailable')}</p>
|
||||
<p className="text-xs text-bambu-gray mb-4 max-w-md">
|
||||
Make sure the printer is powered on and connected.
|
||||
{t('camera.cameraUnavailableDesc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
|
||||
>
|
||||
Retry
|
||||
{t('camera.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -661,7 +663,7 @@ export function CameraPage() {
|
||||
ref={imgRef}
|
||||
key={imageKey}
|
||||
src={currentUrl}
|
||||
alt="Camera stream"
|
||||
alt={t('camera.cameraStream')}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{
|
||||
transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
|
||||
@@ -679,14 +681,14 @@ export function CameraPage() {
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomLevel <= 1}
|
||||
className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
|
||||
title="Zoom out"
|
||||
title={t('camera.zoomOut')}
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
|
||||
title="Reset zoom"
|
||||
title={t('camera.resetZoom')}
|
||||
>
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</button>
|
||||
@@ -694,7 +696,7 @@ export function CameraPage() {
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoomLevel >= 4}
|
||||
className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
|
||||
title="Zoom in"
|
||||
title={t('camera.zoomIn')}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export function ExternalLinkPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { mode } = useTheme();
|
||||
|
||||
@@ -26,7 +28,7 @@ export function ExternalLinkPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
|
||||
<AlertTriangle className="w-12 h-12" />
|
||||
<p>Link not found</p>
|
||||
<p>{t('common.linkNotFound')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
@@ -25,6 +26,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
export function GroupsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -74,7 +76,7 @@ export function GroupsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
setShowCreateModal(false);
|
||||
resetForm();
|
||||
showToast('Group created successfully');
|
||||
showToast(t('groups.toast.created'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -87,7 +89,7 @@ export function GroupsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
setEditingGroup(null);
|
||||
resetForm();
|
||||
showToast('Group updated successfully');
|
||||
showToast(t('groups.toast.updated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -98,7 +100,7 @@ export function GroupsPage() {
|
||||
mutationFn: (id: number) => api.deleteGroup(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
showToast('Group deleted successfully');
|
||||
showToast(t('groups.toast.deleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -112,7 +114,7 @@ export function GroupsPage() {
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.name.trim()) {
|
||||
showToast('Please enter a group name', 'error');
|
||||
showToast(t('groups.toast.enterGroupName'), 'error');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
@@ -125,7 +127,7 @@ export function GroupsPage() {
|
||||
const handleUpdate = () => {
|
||||
if (!editingGroup) return;
|
||||
if (!formData.name.trim()) {
|
||||
showToast('Please enter a group name', 'error');
|
||||
showToast(t('groups.toast.enterGroupName'), 'error');
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
@@ -206,7 +208,7 @@ export function GroupsPage() {
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<Shield className="w-5 h-5" />
|
||||
<p className="text-white">You do not have permission to access this page.</p>
|
||||
<p className="text-white">{t('groups.noPermission')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -283,17 +285,17 @@ export function GroupsPage() {
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=users')}
|
||||
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title="Back to Settings"
|
||||
title={t('groups.backToSettings')}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-bambu-green" />
|
||||
Group Management
|
||||
{t('groups.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
Manage permission groups for access control
|
||||
{t('groups.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,7 +307,7 @@ export function GroupsPage() {
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Group
|
||||
{t('groups.createGroup')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -335,34 +337,34 @@ export function GroupsPage() {
|
||||
<h3 className="text-lg font-semibold text-white">{group.name}</h3>
|
||||
{group.is_system && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
|
||||
System
|
||||
{t('groups.system')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
|
||||
<p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{group.user_count} users</span>
|
||||
<span>{t('groups.usersCount', { count: group.user_count })}</span>
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray">
|
||||
{group.permissions.length} permissions
|
||||
{t('groups.permissionsCount', { count: group.permissions.length })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
|
||||
{hasPermission('groups:update') && (
|
||||
<Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
{t('groups.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('groups:delete') && !group.is_system && (
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
{t('groups.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -391,7 +393,7 @@ export function GroupsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{editingGroup ? 'Edit Group' : 'Create Group'}
|
||||
{editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
@@ -411,7 +413,7 @@ export function GroupsPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Group Name
|
||||
{t('groups.form.groupName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -419,27 +421,27 @@ export function GroupsPage() {
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
disabled={editingGroup?.is_system}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
|
||||
placeholder="Enter group name"
|
||||
placeholder={t('groups.form.groupNamePlaceholder')}
|
||||
/>
|
||||
{editingGroup?.is_system && (
|
||||
<p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
|
||||
<p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Description
|
||||
{t('groups.form.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
|
||||
placeholder="Enter description (optional)"
|
||||
placeholder={t('groups.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Permissions ({formData.permissions.length} selected)
|
||||
{t('groups.form.permissions', { count: formData.permissions.length })}
|
||||
</label>
|
||||
{renderPermissionEditor()}
|
||||
</div>
|
||||
@@ -453,7 +455,7 @@ export function GroupsPage() {
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('groups.modal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={editingGroup ? handleUpdate : handleCreate}
|
||||
@@ -462,12 +464,12 @@ export function GroupsPage() {
|
||||
{(createMutation.isPending || updateMutation.isPending) ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{editingGroup ? 'Saving...' : 'Creating...'}
|
||||
{editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
{editingGroup ? 'Save Changes' : 'Create Group'}
|
||||
{editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -480,9 +482,9 @@ export function GroupsPage() {
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteGroupId !== null && (
|
||||
<ConfirmModal
|
||||
title="Delete Group"
|
||||
message="Are you sure you want to delete this group? Users in this group will lose these permissions."
|
||||
confirmText="Delete Group"
|
||||
title={t('groups.deleteModal.title')}
|
||||
message={t('groups.deleteModal.message')}
|
||||
confirmText={t('groups.deleteModal.confirm')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(deleteGroupId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -8,6 +9,7 @@ import { HelpCircle, X } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { login } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
@@ -18,18 +20,18 @@ export function LoginPage() {
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => login(username, password),
|
||||
onSuccess: () => {
|
||||
showToast('Logged in successfully');
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Login failed', 'error');
|
||||
showToast(error.message || t('login.loginFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
showToast('Please enter username and password', 'error');
|
||||
showToast(t('login.enterCredentials'), 'error');
|
||||
return;
|
||||
}
|
||||
loginMutation.mutate();
|
||||
@@ -47,10 +49,10 @@ export function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white">
|
||||
Bambuddy Login
|
||||
{t('login.title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">
|
||||
Sign in to your account
|
||||
{t('login.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +60,7 @@ export function LoginPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
|
||||
Username
|
||||
{t('login.username')}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
@@ -67,14 +69,14 @@ export function LoginPage() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter your username"
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
{t('login.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -83,7 +85,7 @@ export function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,7 +97,7 @@ export function LoginPage() {
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
|
||||
>
|
||||
{loginMutation.isPending ? 'Logging in...' : 'Sign in'}
|
||||
{loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +107,7 @@ export function LoginPage() {
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
Forgot your password?
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -124,7 +126,7 @@ export function LoginPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Forgot Password</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForgotPassword(false)}
|
||||
@@ -136,16 +138,16 @@ export function LoginPage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray">
|
||||
If you've forgotten your password, please contact your system administrator to reset it.
|
||||
{t('login.forgotPasswordMessage')}
|
||||
</p>
|
||||
|
||||
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-white font-medium">How to reset your password:</p>
|
||||
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
|
||||
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
|
||||
<li>Contact your Bambuddy administrator</li>
|
||||
<li>Ask them to reset your password in User Management</li>
|
||||
<li>They can set a new temporary password for you</li>
|
||||
<li>Log in with the new password and change it in Settings</li>
|
||||
<li>{t('login.resetStep1')}</li>
|
||||
<li>{t('login.resetStep2')}</li>
|
||||
<li>{t('login.resetStep3')}</li>
|
||||
<li>{t('login.resetStep4')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +155,7 @@ export function LoginPage() {
|
||||
onClick={() => setShowForgotPassword(false)}
|
||||
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
|
||||
>
|
||||
Got it
|
||||
{t('login.gotIt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Wrench,
|
||||
@@ -73,15 +74,26 @@ function getIcon(iconName: string | null) {
|
||||
return iconMap[iconName] || Wrench;
|
||||
}
|
||||
|
||||
function formatDuration(value: number, type: 'hours' | 'days'): string {
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function formatDuration(value: number, type: 'hours' | 'days', t?: TFunction): string {
|
||||
if (type === 'days') {
|
||||
if (value < 1) return 'Today';
|
||||
if (value === 1) return '1 day';
|
||||
if (value < 7) return `${Math.round(value)} days`;
|
||||
if (value < 1) return t ? t('common.today') : 'Today';
|
||||
if (value === 1) return t ? t('maintenance.day') : '1 day';
|
||||
if (value < 7) {
|
||||
const days = Math.round(value);
|
||||
return t ? t('maintenance.days', { count: days }) : `${days} days`;
|
||||
}
|
||||
// Show weeks for anything under 6 months for better precision
|
||||
if (value < 180) return `${Math.round(value / 7)} weeks`;
|
||||
if (value < 180) {
|
||||
const weeks = Math.round(value / 7);
|
||||
if (weeks === 1) return t ? t('maintenance.week') : '1 week';
|
||||
return t ? t('maintenance.weeks', { count: weeks }) : `${weeks} weeks`;
|
||||
}
|
||||
// 6+ months show as months
|
||||
return `${Math.round(value / 30)} months`;
|
||||
const months = Math.round(value / 30);
|
||||
if (months === 1) return t ? t('maintenance.month') : '1 month';
|
||||
return t ? t('maintenance.months', { count: months }) : `${months} months`;
|
||||
} else {
|
||||
// Print hours - convert to readable units
|
||||
if (value < 1) return `${Math.round(value * 60)}m`;
|
||||
@@ -97,17 +109,17 @@ function formatDuration(value: number, type: 'hours' | 'days'): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
|
||||
function formatIntervalLabel(value: number, type: 'hours' | 'days', t?: TFunction): string {
|
||||
if (type === 'days') {
|
||||
if (value === 1) return '1 day';
|
||||
if (value === 7) return '1 week';
|
||||
if (value === 14) return '2 weeks';
|
||||
if (value === 30) return '1 month';
|
||||
if (value === 60) return '2 months';
|
||||
if (value === 90) return '3 months';
|
||||
if (value === 180) return '6 months';
|
||||
if (value === 365) return '1 year';
|
||||
return `${value} days`;
|
||||
if (value === 1) return t ? t('maintenance.day') : '1 day';
|
||||
if (value === 7) return t ? t('maintenance.week') : '1 week';
|
||||
if (value === 14) return t ? t('maintenance.weeks', { count: 2 }) : '2 weeks';
|
||||
if (value === 30) return t ? t('maintenance.month') : '1 month';
|
||||
if (value === 60) return t ? t('maintenance.months', { count: 2 }) : '2 months';
|
||||
if (value === 90) return t ? t('maintenance.months', { count: 3 }) : '3 months';
|
||||
if (value === 180) return t ? t('maintenance.months', { count: 6 }) : '6 months';
|
||||
if (value === 365) return t ? t('maintenance.year') : '1 year';
|
||||
return t ? t('maintenance.days', { count: value }) : `${value} days`;
|
||||
}
|
||||
return `${value}h`;
|
||||
}
|
||||
@@ -201,11 +213,13 @@ function MaintenanceCard({
|
||||
onPerform,
|
||||
onToggle,
|
||||
hasPermission,
|
||||
t,
|
||||
}: {
|
||||
item: MaintenanceStatus;
|
||||
onPerform: (id: number) => void;
|
||||
onToggle: (id: number, enabled: boolean) => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const Icon = getIcon(item.maintenance_type_icon);
|
||||
const intervalType = item.interval_type || 'hours';
|
||||
@@ -245,17 +259,17 @@ function MaintenanceCard({
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!item.enabled) return 'Disabled';
|
||||
if (!item.enabled) return t('common.disabled');
|
||||
|
||||
if (intervalType === 'days') {
|
||||
const daysUntil = item.days_until_due ?? 0;
|
||||
if (item.is_due) return `Overdue by ${formatDuration(Math.abs(daysUntil), 'days')}`;
|
||||
if (item.is_warning) return `Due in ${formatDuration(daysUntil, 'days')}`;
|
||||
return `${formatDuration(daysUntil, 'days')} left`;
|
||||
if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(daysUntil), 'days', t) });
|
||||
if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(daysUntil, 'days', t) });
|
||||
return t('maintenance.timeLeft', { duration: formatDuration(daysUntil, 'days', t) });
|
||||
} else {
|
||||
if (item.is_due) return `Overdue by ${formatDuration(Math.abs(item.hours_until_due), 'hours')}`;
|
||||
if (item.is_warning) return `Due in ${formatDuration(item.hours_until_due, 'hours')}`;
|
||||
return `${formatDuration(item.hours_until_due, 'hours')} left`;
|
||||
if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(item.hours_until_due), 'hours', t) });
|
||||
if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(item.hours_until_due, 'hours', t) });
|
||||
return t('maintenance.timeLeft', { duration: formatDuration(item.hours_until_due, 'hours', t) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -283,7 +297,7 @@ function MaintenanceCard({
|
||||
{item.maintenance_type_name}
|
||||
</h3>
|
||||
{intervalType === 'days' && (
|
||||
<span title="Time-based interval">
|
||||
<span title={t('maintenance.timeBasedInterval')}>
|
||||
<Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
@@ -297,7 +311,7 @@ function MaintenanceCard({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors shrink-0"
|
||||
title="View documentation"
|
||||
title={t('maintenance.viewDocumentation')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
@@ -327,7 +341,7 @@ function MaintenanceCard({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
|
||||
<span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>
|
||||
<Toggle
|
||||
checked={item.enabled}
|
||||
onChange={(checked) => onToggle(item.id, checked)}
|
||||
@@ -339,11 +353,11 @@ function MaintenanceCard({
|
||||
variant={item.is_due ? 'primary' : 'secondary'}
|
||||
onClick={() => onPerform(item.id)}
|
||||
disabled={!item.enabled || !hasPermission('maintenance:update')}
|
||||
title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
|
||||
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionPerform') : undefined}
|
||||
className="!px-3"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,12 +372,14 @@ function PrinterSection({
|
||||
onToggle,
|
||||
onSetHours,
|
||||
hasPermission,
|
||||
t,
|
||||
}: {
|
||||
overview: PrinterMaintenanceOverview;
|
||||
onPerform: (id: number) => void;
|
||||
onToggle: (id: number, enabled: boolean) => void;
|
||||
onSetHours: (printerId: number, hours: number) => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [editingHours, setEditingHours] = useState(false);
|
||||
@@ -399,19 +415,19 @@ function PrinterSection({
|
||||
{overview.due_count > 0 && (
|
||||
<span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{overview.due_count} overdue
|
||||
{t('maintenance.overdueCount', { count: overview.due_count })}
|
||||
</span>
|
||||
)}
|
||||
{overview.warning_count > 0 && (
|
||||
<span className="px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{overview.warning_count} due soon
|
||||
{t('maintenance.dueSoonCount', { count: overview.warning_count })}
|
||||
</span>
|
||||
)}
|
||||
{overview.due_count === 0 && overview.warning_count === 0 && (
|
||||
<span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5">
|
||||
<Check className="w-3 h-3" />
|
||||
All good
|
||||
{t('maintenance.allGood')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -421,7 +437,7 @@ function PrinterSection({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
{expanded ? t('common.collapse') : t('common.expand')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -447,9 +463,9 @@ function PrinterSection({
|
||||
step="1"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-xs text-bambu-gray">hours</span>
|
||||
<Button size="sm" onClick={handleSaveHours}>Save</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>Cancel</Button>
|
||||
<span className="text-xs text-bambu-gray">{t('common.hours')}</span>
|
||||
<Button size="sm" onClick={handleSaveHours}>{t('common.save')}</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -459,13 +475,13 @@ function PrinterSection({
|
||||
setEditingHours(true);
|
||||
}}
|
||||
className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
|
||||
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditHours') : undefined}
|
||||
>
|
||||
<div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
|
||||
{Math.round(overview.total_print_hours)} hours
|
||||
{Math.round(overview.total_print_hours)} {t('common.hours')}
|
||||
<Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray">Total print time</div>
|
||||
<div className="text-xs text-bambu-gray">{t('maintenance.totalPrintTime')}</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -489,7 +505,7 @@ function PrinterSection({
|
||||
{nextTask.maintenance_type_name}
|
||||
</div>
|
||||
<div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>
|
||||
{nextTask.is_due ? 'Overdue' : 'Due soon'}
|
||||
{nextTask.is_due ? t('common.overdue') : t('maintenance.dueSoon')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,6 +524,7 @@ function PrinterSection({
|
||||
onPerform={onPerform}
|
||||
onToggle={onToggle}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -528,6 +545,7 @@ function SettingsSection({
|
||||
onAssignType,
|
||||
onRemoveItem,
|
||||
hasPermission,
|
||||
t,
|
||||
}: {
|
||||
overview: PrinterMaintenanceOverview[] | undefined;
|
||||
types: MaintenanceType[];
|
||||
@@ -538,6 +556,7 @@ function SettingsSection({
|
||||
onAssignType: (printerId: number, typeId: number) => void;
|
||||
onRemoveItem: (itemId: number) => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const [editingInterval, setEditingInterval] = useState<number | null>(null);
|
||||
const [intervalInput, setIntervalInput] = useState('');
|
||||
@@ -665,16 +684,16 @@ function SettingsSection({
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
|
||||
<h2 className="text-lg font-semibold text-white">{t('maintenance.maintenanceTypes')}</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.maintenanceTypesDescription')}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddType(!showAddType)}
|
||||
disabled={!hasPermission('maintenance:create')}
|
||||
title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
|
||||
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Custom Type
|
||||
{t('maintenance.addCustomType')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -685,18 +704,18 @@ function SettingsSection({
|
||||
<form onSubmit={handleAddType}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">Name</label>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">{t('common.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTypeName}
|
||||
onChange={(e) => setNewTypeName(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:border-bambu-green focus:outline-none"
|
||||
placeholder="e.g., Replace HEPA Filter"
|
||||
placeholder={t('maintenance.exampleName')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">Interval Type</label>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.intervalType')}</label>
|
||||
<select
|
||||
value={newTypeIntervalType}
|
||||
onChange={(e) => {
|
||||
@@ -710,13 +729,13 @@ function SettingsSection({
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="hours">Print Hours</option>
|
||||
<option value="days">Calendar Days</option>
|
||||
<option value="hours">{t('maintenance.printHours')}</option>
|
||||
<option value="days">{t('maintenance.calendarDays')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">
|
||||
Interval ({newTypeIntervalType === 'days' ? 'days' : 'hours'})
|
||||
{t('maintenance.intervalValue', { type: newTypeIntervalType === 'days' ? t('maintenance.calendarDays').toLowerCase() : t('common.hours') })}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -729,7 +748,7 @@ function SettingsSection({
|
||||
</div>
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">Icon</label>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.icon')}</label>
|
||||
<div className="flex gap-1">
|
||||
{Object.keys(iconMap).map((iconName) => {
|
||||
const IconComp = iconMap[iconName];
|
||||
@@ -753,7 +772,7 @@ function SettingsSection({
|
||||
</div>
|
||||
{/* Wiki URL */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">Documentation Link (optional)</label>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.documentationLink')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newTypeWikiUrl}
|
||||
@@ -764,7 +783,7 @@ function SettingsSection({
|
||||
</div>
|
||||
{/* Printer selection */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
|
||||
<label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.assignToPrinters')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{printers.map(p => (
|
||||
<button
|
||||
@@ -782,15 +801,15 @@ function SettingsSection({
|
||||
))}
|
||||
</div>
|
||||
{selectedPrinters.size === 0 && (
|
||||
<p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
|
||||
<p className="text-xs text-orange-400 mt-1">{t('maintenance.selectAtLeastOnePrinter')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
|
||||
Add Type
|
||||
{t('maintenance.addType')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -814,7 +833,7 @@ function SettingsSection({
|
||||
<div className="text-sm font-medium text-white truncate">{type.name}</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
|
||||
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
|
||||
{formatIntervalLabel(type.default_interval_hours, intervalType)}
|
||||
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -836,7 +855,7 @@ function SettingsSection({
|
||||
value={editTypeName}
|
||||
onChange={(e) => setEditTypeName(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:border-bambu-green focus:outline-none"
|
||||
placeholder="Name"
|
||||
placeholder={t('common.name')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -845,8 +864,8 @@ function SettingsSection({
|
||||
onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}
|
||||
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="hours">Print Hours</option>
|
||||
<option value="days">Calendar Days</option>
|
||||
<option value="hours">{t('maintenance.printHours')}</option>
|
||||
<option value="days">{t('maintenance.calendarDays')}</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
@@ -880,14 +899,14 @@ function SettingsSection({
|
||||
value={editTypeWikiUrl}
|
||||
onChange={(e) => setEditTypeWikiUrl(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:border-bambu-green focus:outline-none"
|
||||
placeholder="Documentation link (optional)"
|
||||
placeholder={t('maintenance.documentationLink')}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingType(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -909,12 +928,12 @@ function SettingsSection({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{type.name}</span>
|
||||
<span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">
|
||||
Custom
|
||||
{t('maintenance.custom')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
|
||||
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
|
||||
{formatIntervalLabel(type.default_interval_hours, intervalType)}
|
||||
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -924,7 +943,7 @@ function SettingsSection({
|
||||
? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
|
||||
: 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
|
||||
}`}
|
||||
title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
|
||||
title={t('maintenance.printersAssignedClick', { count: assignedPrinters.length })}
|
||||
>
|
||||
<Printer className="w-3 h-3" />
|
||||
<span className="text-xs font-medium">{assignedPrinters.length}</span>
|
||||
@@ -933,19 +952,19 @@ function SettingsSection({
|
||||
<button
|
||||
onClick={() => startEditType(type)}
|
||||
disabled={!hasPermission('maintenance:update')}
|
||||
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
|
||||
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditTypes') : undefined}
|
||||
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${type.name}"?`)) {
|
||||
if (confirm(t('maintenance.deleteTypeConfirm', { name: type.name }))) {
|
||||
onDeleteType(type.id);
|
||||
}
|
||||
}}
|
||||
disabled={!hasPermission('maintenance:delete')}
|
||||
title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
|
||||
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
|
||||
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -955,9 +974,9 @@ function SettingsSection({
|
||||
{/* Printer assignment management */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
|
||||
<p className="text-xs text-bambu-gray mb-2">{t('maintenance.assignedToPrinters')}</p>
|
||||
{assignedPrinters.length === 0 ? (
|
||||
<p className="text-xs text-orange-400">No printers assigned</p>
|
||||
<p className="text-xs text-orange-400">{t('maintenance.noPrintersAssigned')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{assignedPrinters.map(p => (
|
||||
@@ -969,7 +988,7 @@ function SettingsSection({
|
||||
<button
|
||||
onClick={() => p.itemId && onRemoveItem(p.itemId)}
|
||||
disabled={!hasPermission('maintenance:delete')}
|
||||
title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
|
||||
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionRemovePrinter') : t('maintenance.removeFromPrinter')}
|
||||
className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
×
|
||||
@@ -980,13 +999,13 @@ function SettingsSection({
|
||||
)}
|
||||
{unassignedPrinters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="text-xs text-bambu-gray mr-1">Add:</span>
|
||||
<span className="text-xs text-bambu-gray mr-1">{t('maintenance.addPrinterShort')}</span>
|
||||
{unassignedPrinters.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onAssignType(p.id, type.id)}
|
||||
disabled={!hasPermission('maintenance:create')}
|
||||
title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
|
||||
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionAssignPrinter') : undefined}
|
||||
className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
|
||||
>
|
||||
+ {p.name}
|
||||
@@ -1006,8 +1025,8 @@ function SettingsSection({
|
||||
{printerItems.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Interval Overrides</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">Customize intervals for specific printers</p>
|
||||
<h2 className="text-lg font-semibold text-white">{t('maintenance.intervalOverrides')}</h2>
|
||||
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.intervalOverridesDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{printerItems.map((printer) => (
|
||||
@@ -1040,8 +1059,8 @@ function SettingsSection({
|
||||
onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}
|
||||
className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
|
||||
>
|
||||
<option value="hours">Print Hours</option>
|
||||
<option value="days">Calendar Days</option>
|
||||
<option value="hours">{t('maintenance.printHours')}</option>
|
||||
<option value="days">{t('maintenance.calendarDays')}</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1065,11 +1084,11 @@ function SettingsSection({
|
||||
setIntervalTypeInput(intervalType);
|
||||
}}
|
||||
disabled={!hasPermission('maintenance:update')}
|
||||
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
|
||||
title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditIntervals') : undefined}
|
||||
className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
|
||||
>
|
||||
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
|
||||
{formatIntervalLabel(item.interval_hours, intervalType)}
|
||||
{formatIntervalLabel(item.interval_hours, intervalType, t)}
|
||||
<Edit3 className="w-3 h-3 text-bambu-gray" />
|
||||
</button>
|
||||
)}
|
||||
@@ -1088,9 +1107,9 @@ function SettingsSection({
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
|
||||
<p className="text-bambu-gray">No printers configured</p>
|
||||
<p className="text-bambu-gray">{t('common.noPrinters')}</p>
|
||||
<p className="text-sm text-bambu-gray/70 mt-1">
|
||||
Add printers to configure maintenance intervals
|
||||
{t('maintenance.intervalOverridesDescription')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1102,6 +1121,7 @@ function SettingsSection({
|
||||
type TabType = 'status' | 'settings';
|
||||
|
||||
export function MaintenancePage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -1123,7 +1143,7 @@ export function MaintenancePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
|
||||
showToast('Maintenance marked as complete');
|
||||
showToast(t('maintenance.maintenanceComplete'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1150,7 +1170,7 @@ export function MaintenancePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
showToast('Maintenance type updated');
|
||||
showToast(t('maintenance.typeUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1162,7 +1182,7 @@ export function MaintenancePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
showToast('Maintenance type deleted');
|
||||
showToast(t('maintenance.typeDeleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1175,7 +1195,7 @@ export function MaintenancePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
|
||||
showToast('Print hours updated');
|
||||
showToast(t('maintenance.printHoursUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1187,7 +1207,7 @@ export function MaintenancePage() {
|
||||
api.assignMaintenanceType(printerId, typeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
showToast('Printer assigned');
|
||||
showToast(t('maintenance.printerAssigned'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1198,7 +1218,7 @@ export function MaintenancePage() {
|
||||
mutationFn: api.removeMaintenanceItem,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
showToast('Printer removed');
|
||||
showToast(t('maintenance.printerRemoved'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -1232,17 +1252,17 @@ export function MaintenancePage() {
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
|
||||
<h1 className="text-2xl font-bold text-white">{t('maintenance.title')}</h1>
|
||||
<p className="text-bambu-gray text-sm mt-1">
|
||||
{activeTab === 'status' ? (
|
||||
<>
|
||||
{totalDue > 0 && <span className="text-red-400">{totalDue} task{totalDue !== 1 ? 's' : ''} overdue</span>}
|
||||
{totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
|
||||
{totalDue > 0 && totalWarning > 0 && ' · '}
|
||||
{totalWarning > 0 && <span className="text-amber-400">{totalWarning} due soon</span>}
|
||||
{totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">All maintenance up to date</span>}
|
||||
{totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
|
||||
{totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
|
||||
</>
|
||||
) : (
|
||||
'Configure maintenance types and intervals'
|
||||
t('maintenance.configureSettings')
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1257,7 +1277,7 @@ export function MaintenancePage() {
|
||||
: 'text-bambu-gray border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Status
|
||||
{t('maintenance.statusTab')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
@@ -1267,7 +1287,7 @@ export function MaintenancePage() {
|
||||
: 'text-bambu-gray border-transparent hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
{t('maintenance.settingsTab')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1289,14 +1309,15 @@ export function MaintenancePage() {
|
||||
onToggle={handleToggle}
|
||||
onSetHours={handleSetHours}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="text-center py-16">
|
||||
<Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
|
||||
<p className="text-lg font-medium text-white mb-2">No printers configured</p>
|
||||
<p className="text-bambu-gray">Add printers to start tracking maintenance</p>
|
||||
<p className="text-lg font-medium text-white mb-2">{t('common.noPrinters')}</p>
|
||||
<p className="text-bambu-gray">{t('maintenance.configureSettings')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1317,13 +1338,14 @@ export function MaintenancePage() {
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
showToast('Maintenance type added');
|
||||
showToast(t('maintenance.typeUpdated'));
|
||||
}}
|
||||
onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
|
||||
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
|
||||
onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
|
||||
onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+231
-226
File diff suppressed because it is too large
Load Diff
+227
-203
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit3,
|
||||
@@ -59,7 +60,9 @@ function formatFilament(grams: number): string {
|
||||
return `${Math.round(grams)}g`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function StatusBadge({ status, t }: { status: string; t: TFunction }) {
|
||||
const colors = {
|
||||
active: 'bg-bambu-green/20 text-bambu-green',
|
||||
completed: 'bg-blue-500/20 text-blue-400',
|
||||
@@ -67,9 +70,15 @@ function StatusBadge({ status }: { status: string }) {
|
||||
};
|
||||
const color = colors[status as keyof typeof colors] || colors.active;
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
active: t('projectDetail.status.active'),
|
||||
completed: t('projectDetail.status.completed'),
|
||||
archived: t('projectDetail.status.archived'),
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -107,12 +116,12 @@ function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveGrid({ archives }: { archives: Archive[] }) {
|
||||
function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
|
||||
if (archives.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No prints in this project yet</p>
|
||||
<p>{t('projectDetail.noPrints')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,12 +168,12 @@ function ArchiveGrid({ archives }: { archives: Archive[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityBadge({ priority }: { priority: string }) {
|
||||
function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
|
||||
const config = {
|
||||
low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
|
||||
normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
|
||||
high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
|
||||
urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
|
||||
low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') },
|
||||
normal: { color: 'bg-blue-500/20 text-blue-400', label: t('projectDetail.priority.normal') },
|
||||
high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') },
|
||||
urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') },
|
||||
};
|
||||
const { color, label } = config[priority as keyof typeof config] || config.normal;
|
||||
|
||||
@@ -181,22 +190,23 @@ function formatDate(dateString: string | null): string {
|
||||
return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
|
||||
function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
|
||||
if (!dateString) return null;
|
||||
const dueDate = parseUTCDate(dateString);
|
||||
if (!dueDate) return null;
|
||||
const now = new Date();
|
||||
const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
|
||||
if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
|
||||
if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
|
||||
return { color: 'text-bambu-gray', label: `${diffDays} days left` };
|
||||
if (diffDays < 0) return { color: 'text-red-400', label: t('projectDetail.dueDate.overdue') };
|
||||
if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') };
|
||||
if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
|
||||
return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
|
||||
}
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -251,7 +261,7 @@ export function ProjectDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
setShowEditModal(false);
|
||||
setEditingNotes(false);
|
||||
showToast('Project updated', 'success');
|
||||
showToast(t('projectDetail.toast.projectUpdated'), 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -306,7 +316,7 @@ export function ProjectDetailPage() {
|
||||
setNewBomUrl('');
|
||||
setNewBomRemarks('');
|
||||
setShowBomForm(false);
|
||||
showToast('Part added', 'success');
|
||||
showToast(t('projectDetail.toast.partAdded'), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
@@ -327,7 +337,7 @@ export function ProjectDetailPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
|
||||
showToast('Part removed', 'success');
|
||||
showToast(t('projectDetail.toast.partRemoved'), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
@@ -355,8 +365,8 @@ export function ProjectDetailPage() {
|
||||
const handleDeleteBomItem = (itemId: number, itemName: string) => {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: 'Delete Part',
|
||||
message: `Are you sure you want to delete "${itemName}"?`,
|
||||
title: t('projectDetail.bom.deletePart'),
|
||||
message: t('projectDetail.bom.deleteConfirm', { name: itemName }),
|
||||
onConfirm: () => {
|
||||
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||
deleteBomMutation.mutate(itemId);
|
||||
@@ -397,7 +407,7 @@ export function ProjectDetailPage() {
|
||||
// Fetch ZIP file directly
|
||||
const response = await fetch(`/api/v1/projects/${projectId}/export`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
throw new Error(t('projectDetail.toast.exportFailed'));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -409,7 +419,7 @@ export function ProjectDetailPage() {
|
||||
a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Project exported', 'success');
|
||||
showToast(t('projectDetail.toast.projectExported'), 'success');
|
||||
} catch (error) {
|
||||
showToast((error as Error).message, 'error');
|
||||
}
|
||||
@@ -420,7 +430,7 @@ export function ProjectDetailPage() {
|
||||
mutationFn: () => api.createTemplateFromProject(projectId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast('Template created', 'success');
|
||||
showToast(t('projectDetail.toast.templateCreated'), 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
@@ -446,10 +456,10 @@ export function ProjectDetailPage() {
|
||||
return (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-bambu-gray">
|
||||
{projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
|
||||
{projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
|
||||
</p>
|
||||
<Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
|
||||
Back to Projects
|
||||
{t('projectDetail.backToProjects')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -466,7 +476,7 @@ export function ProjectDetailPage() {
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<Link to="/projects" className="hover:text-white transition-colors">
|
||||
Projects
|
||||
{t('navigation.projects')}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-white">{project.name}</span>
|
||||
@@ -493,25 +503,25 @@ export function ProjectDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
<StatusBadge status={project.status} t={t} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExportProject}
|
||||
disabled={!hasPermission('projects:read')}
|
||||
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
|
||||
title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
{t('projectDetail.export')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowEditModal(true)}
|
||||
disabled={!hasPermission('projects:update')}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -524,9 +534,9 @@ export function ProjectDetailPage() {
|
||||
{project.target_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">Plates Progress</span>
|
||||
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{stats?.total_archives || 0} / {project.target_count} print jobs
|
||||
{stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
@@ -540,11 +550,11 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{platesProgressPercent.toFixed(0)}% complete
|
||||
{t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
|
||||
</span>
|
||||
{stats?.remaining_prints != null && stats.remaining_prints > 0 && (
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{stats.remaining_prints} remaining
|
||||
{t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -554,9 +564,9 @@ export function ProjectDetailPage() {
|
||||
{project.target_parts_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">Parts Progress</span>
|
||||
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{stats?.completed_prints || 0} / {project.target_parts_count} parts
|
||||
{stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
@@ -570,11 +580,11 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{partsProgressPercent.toFixed(0)}% complete
|
||||
{t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
|
||||
</span>
|
||||
{stats?.remaining_parts != null && stats.remaining_parts > 0 && (
|
||||
<span className="text-xs text-bambu-gray/70">
|
||||
{stats.remaining_parts} remaining
|
||||
{t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -594,25 +604,25 @@ export function ProjectDetailPage() {
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray">Print Jobs</p>
|
||||
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
|
||||
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
|
||||
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">{t('projectDetail.stats.total')}</span></p>
|
||||
{stats.failed_prints > 0 && (
|
||||
<p className="text-sm text-status-error">{stats.failed_prints} failed</p>
|
||||
<p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
|
||||
)}
|
||||
<p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
|
||||
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Print Time"
|
||||
label={t('projectDetail.stats.printTime')}
|
||||
value={formatDuration(stats.total_print_time_hours)}
|
||||
color="text-yellow-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Printer}
|
||||
label="Filament Used"
|
||||
label={t('projectDetail.stats.filamentUsed')}
|
||||
value={formatFilament(stats.total_filament_grams)}
|
||||
color="text-purple-400"
|
||||
/>
|
||||
@@ -624,18 +634,18 @@ export function ProjectDetailPage() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-semibold text-white mb-3">
|
||||
Cost Tracking
|
||||
{t('projectDetail.cost.title')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{currency}{stats.estimated_cost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
{stats.total_energy_kwh > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">Energy</p>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{stats.total_energy_kwh.toFixed(2)} kWh
|
||||
{stats.total_energy_cost > 0 && (
|
||||
@@ -649,11 +659,11 @@ export function ProjectDetailPage() {
|
||||
{project.budget && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">Budget</p>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
|
||||
<p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">Remaining</p>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.remaining')}</p>
|
||||
<p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
|
||||
{currency}{(project.budget - stats.estimated_cost).toFixed(2)}
|
||||
</p>
|
||||
@@ -671,7 +681,7 @@ export function ProjectDetailPage() {
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
Sub-projects ({project.children.length})
|
||||
{t('projectDetail.subProjects.title', { count: project.children.length })}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{project.children.map((child) => (
|
||||
@@ -710,7 +720,7 @@ export function ProjectDetailPage() {
|
||||
{project.parent_id && project.parent_name && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Layers className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-bambu-gray">Part of:</span>
|
||||
<span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
|
||||
<Link
|
||||
to={`/projects/${project.parent_id}`}
|
||||
className="text-bambu-green hover:underline"
|
||||
@@ -726,8 +736,8 @@ export function ProjectDetailPage() {
|
||||
{/* Priority */}
|
||||
{project.priority && project.priority !== 'normal' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-bambu-gray uppercase">Priority:</span>
|
||||
<PriorityBadge priority={project.priority} />
|
||||
<span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
|
||||
<PriorityBadge priority={project.priority} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -736,9 +746,9 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-sm text-white">{formatDate(project.due_date)}</span>
|
||||
{getDueDateStatus(project.due_date) && (
|
||||
<span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
|
||||
({getDueDateStatus(project.due_date)!.label})
|
||||
{getDueDateStatus(project.due_date, t) && (
|
||||
<span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
|
||||
({getDueDateStatus(project.due_date, t)!.label})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -769,7 +779,7 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Notes
|
||||
{t('projectDetail.notes.title')}
|
||||
</h2>
|
||||
{!editingNotes ? (
|
||||
<Button
|
||||
@@ -777,10 +787,10 @@ export function ProjectDetailPage() {
|
||||
size="sm"
|
||||
onClick={handleStartEditNotes}
|
||||
disabled={!hasPermission('projects:update')}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
@@ -791,7 +801,7 @@ export function ProjectDetailPage() {
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -803,7 +813,7 @@ export function ProjectDetailPage() {
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -813,7 +823,7 @@ export function ProjectDetailPage() {
|
||||
<RichTextEditor
|
||||
content={notesContent}
|
||||
onChange={setNotesContent}
|
||||
placeholder="Add notes about this project..."
|
||||
placeholder={t('projectDetail.notes.placeholder')}
|
||||
/>
|
||||
) : project.notes ? (
|
||||
<div
|
||||
@@ -822,7 +832,7 @@ export function ProjectDetailPage() {
|
||||
/>
|
||||
) : (
|
||||
<p className="text-bambu-gray/70 text-sm italic">
|
||||
No notes yet. Click Edit to add notes.
|
||||
{t('projectDetail.notes.empty')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -834,15 +844,15 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
Files
|
||||
{t('projectDetail.files.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-bambu-gray mb-3">
|
||||
<Link to="/files" className="text-bambu-green hover:underline">
|
||||
Link folders from the File Manager
|
||||
{t('projectDetail.files.linkFolders')}
|
||||
</Link>
|
||||
{' '}to this project for quick access.
|
||||
{' '}{t('projectDetail.files.forQuickAccess')}
|
||||
</p>
|
||||
|
||||
{linkedFolders && linkedFolders.length > 0 ? (
|
||||
@@ -860,7 +870,7 @@ export function ProjectDetailPage() {
|
||||
{folder.name}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
|
||||
{t('projectDetail.files.fileCount', { count: folder.file_count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,7 +880,7 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-bambu-gray/70 text-sm italic">
|
||||
No folders linked. Go to File Manager and link a folder to this project.
|
||||
{t('projectDetail.files.empty')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -882,10 +892,10 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Bill of Materials
|
||||
{t('projectDetail.bom.title')}
|
||||
{stats && stats.bom_total_items > 0 && (
|
||||
<span className="text-sm font-normal text-bambu-gray">
|
||||
({stats.bom_completed_items}/{stats.bom_total_items} acquired)
|
||||
({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
@@ -899,7 +909,7 @@ export function ProjectDetailPage() {
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{hideBomCompleted ? 'Show all' : 'Hide done'}
|
||||
{hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
|
||||
</button>
|
||||
)}
|
||||
{!showBomForm && (
|
||||
@@ -908,10 +918,10 @@ export function ProjectDetailPage() {
|
||||
size="sm"
|
||||
onClick={() => setShowBomForm(true)}
|
||||
disabled={!hasPermission('projects:update')}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Part
|
||||
{t('projectDetail.bom.addPart')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -926,7 +936,7 @@ export function ProjectDetailPage() {
|
||||
value={newBomName}
|
||||
onChange={(e) => setNewBomName(e.target.value)}
|
||||
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Part name (e.g., M3x8 screws)"
|
||||
placeholder={t('projectDetail.bom.partNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -936,7 +946,7 @@ export function ProjectDetailPage() {
|
||||
onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
|
||||
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
min="1"
|
||||
placeholder="Qty"
|
||||
placeholder={t('projectDetail.bom.qty')}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@@ -944,7 +954,7 @@ export function ProjectDetailPage() {
|
||||
value={newBomPrice}
|
||||
onChange={(e) => setNewBomPrice(e.target.value)}
|
||||
className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder={`Price (${currency})`}
|
||||
placeholder={t('projectDetail.bom.price', { currency })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -953,24 +963,24 @@ export function ProjectDetailPage() {
|
||||
value={newBomUrl}
|
||||
onChange={(e) => setNewBomUrl(e.target.value)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Sourcing URL (optional)"
|
||||
placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newBomRemarks}
|
||||
onChange={(e) => setNewBomRemarks(e.target.value)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Remarks (optional)"
|
||||
placeholder={t('projectDetail.bom.remarksPlaceholder')}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
|
||||
{createBomMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Add Part'
|
||||
t('projectDetail.bom.addPart')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1001,7 +1011,7 @@ export function ProjectDetailPage() {
|
||||
value={editBomName}
|
||||
onChange={(e) => setEditBomName(e.target.value)}
|
||||
className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Part name"
|
||||
placeholder={t('projectDetail.bom.partName')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -1011,7 +1021,7 @@ export function ProjectDetailPage() {
|
||||
onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
|
||||
className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
|
||||
min="1"
|
||||
placeholder="Qty"
|
||||
placeholder={t('projectDetail.bom.qty')}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1019,7 +1029,7 @@ export function ProjectDetailPage() {
|
||||
value={editBomPrice}
|
||||
onChange={(e) => setEditBomPrice(e.target.value)}
|
||||
className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder={`Price (${currency})`}
|
||||
placeholder={t('projectDetail.bom.price', { currency })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1028,24 +1038,24 @@ export function ProjectDetailPage() {
|
||||
value={editBomUrl}
|
||||
onChange={(e) => setEditBomUrl(e.target.value)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Sourcing URL (optional)"
|
||||
placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editBomRemarks}
|
||||
onChange={(e) => setEditBomRemarks(e.target.value)}
|
||||
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="Remarks (optional)"
|
||||
placeholder={t('projectDetail.bom.remarksPlaceholder')}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
|
||||
{updateBomMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Save'
|
||||
t('common.save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1056,7 +1066,7 @@ export function ProjectDetailPage() {
|
||||
<button
|
||||
onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
|
||||
disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
|
||||
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
|
||||
item.is_complete
|
||||
? 'bg-status-ok border-status-ok text-white'
|
||||
@@ -1091,7 +1101,7 @@ export function ProjectDetailPage() {
|
||||
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -1103,7 +1113,7 @@ export function ProjectDetailPage() {
|
||||
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
|
||||
title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -1144,7 +1154,7 @@ export function ProjectDetailPage() {
|
||||
{/* BOM Total */}
|
||||
{bomItems.some(item => item.unit_price !== null) && (
|
||||
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
|
||||
<span className="text-bambu-gray">Total cost:</span>
|
||||
<span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
|
||||
<span className="text-white font-medium">
|
||||
{currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
|
||||
</span>
|
||||
@@ -1153,7 +1163,7 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-bambu-gray/70 text-sm italic">
|
||||
No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
|
||||
{t('projectDetail.bom.empty')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -1165,7 +1175,7 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<History className="w-5 h-5" />
|
||||
Activity Timeline
|
||||
{t('projectDetail.timeline.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -1201,7 +1211,7 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-bambu-gray/70 text-sm italic">
|
||||
No activity yet.
|
||||
{t('projectDetail.timeline.empty')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -1215,14 +1225,14 @@ export function ProjectDetailPage() {
|
||||
size="sm"
|
||||
onClick={() => createTemplateMutation.mutate()}
|
||||
disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
|
||||
title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
|
||||
title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
|
||||
>
|
||||
{createTemplateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Save as Template
|
||||
{t('projectDetail.template.saveAsTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1234,24 +1244,24 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<ListTodo className="w-5 h-5" />
|
||||
Queue
|
||||
{t('projectDetail.queue.title')}
|
||||
</h2>
|
||||
<Link
|
||||
to={`/queue?project=${projectId}`}
|
||||
className="text-sm text-bambu-green hover:underline"
|
||||
>
|
||||
View all
|
||||
{t('projectDetail.queue.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{stats.in_progress_prints > 0 && (
|
||||
<span className="text-yellow-400">
|
||||
{stats.in_progress_prints} printing
|
||||
{t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
|
||||
</span>
|
||||
)}
|
||||
{stats.queued_prints > 0 && (
|
||||
<span className="text-bambu-gray">
|
||||
{stats.queued_prints} queued
|
||||
{t('projectDetail.queue.queued', { count: stats.queued_prints })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1264,7 +1274,7 @@ export function ProjectDetailPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
Prints ({archives?.length || 0})
|
||||
{t('projectDetail.prints.title', { count: archives?.length || 0 })}
|
||||
</h2>
|
||||
</div>
|
||||
{archivesLoading ? (
|
||||
@@ -1272,13 +1282,14 @@ export function ProjectDetailPage() {
|
||||
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
) : (
|
||||
<ArchiveGrid archives={archives || []} />
|
||||
<ArchiveGrid archives={archives || []} t={t} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && (
|
||||
<ProjectModal
|
||||
t={t}
|
||||
project={{
|
||||
...project,
|
||||
archive_count: stats?.total_archives || 0,
|
||||
@@ -1300,7 +1311,7 @@ export function ProjectDetailPage() {
|
||||
<ConfirmModal
|
||||
title={confirmModal.title}
|
||||
message={confirmModal.message}
|
||||
confirmText="Delete"
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={confirmModal.onConfirm}
|
||||
onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -38,14 +39,17 @@ const PROJECT_COLORS = [
|
||||
'#6b7280', // gray
|
||||
];
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
interface ProjectModalProps {
|
||||
project?: ProjectListItem;
|
||||
onClose: () => void;
|
||||
onSave: (data: ProjectCreate | ProjectUpdate) => void;
|
||||
isLoading: boolean;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
|
||||
export function ProjectModal({ project, onClose, onSave, isLoading, t }: ProjectModalProps) {
|
||||
const [name, setName] = useState(project?.name || '');
|
||||
const [description, setDescription] = useState(project?.description || '');
|
||||
const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
|
||||
@@ -76,41 +80,41 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
||||
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{project ? 'Edit Project' : 'New Project'}
|
||||
{project ? t('projects.editProject') : t('projects.newProject')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Name
|
||||
{t('common.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., Voron 2.4 Build"
|
||||
placeholder={t('projects.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Description
|
||||
{t('common.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
|
||||
placeholder="Optional description..."
|
||||
placeholder={t('projects.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Color
|
||||
{t('projects.color')}
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PROJECT_COLORS.map((c) => (
|
||||
@@ -131,45 +135,45 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Target Plates
|
||||
{t('projects.targetPlates')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetCount}
|
||||
onChange={(e) => setTargetCount(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., 25"
|
||||
placeholder={t('projects.targetPlatesPlaceholder')}
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Target Parts
|
||||
{t('projects.targetParts')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetPartsCount}
|
||||
onChange={(e) => setTargetPartsCount(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., 150"
|
||||
placeholder={t('projects.targetPartsPlaceholder')}
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Tags (comma-separated)
|
||||
{t('projects.tagsLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
|
||||
placeholder="e.g., voron, functional, gift"
|
||||
placeholder={t('projects.tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -177,7 +181,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Due Date
|
||||
{t('projects.dueDate')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -188,17 +192,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Priority
|
||||
{t('projects.priority')}
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="low">{t('projects.priorityLow')}</option>
|
||||
<option value="normal">{t('projects.priorityNormal')}</option>
|
||||
<option value="high">{t('projects.priorityHigh')}</option>
|
||||
<option value="urgent">{t('projects.priorityUrgent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,31 +210,31 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
|
||||
{project && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
Status
|
||||
{t('common.status')}
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="active">{t('projects.statusActive')}</option>
|
||||
<option value="completed">{t('projects.statusCompleted')}</option>
|
||||
<option value="archived">{t('projects.statusArchived')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : project ? (
|
||||
'Save'
|
||||
t('common.save')
|
||||
) : (
|
||||
'Create'
|
||||
t('projects.create')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -246,9 +250,10 @@ interface ProjectCardProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
|
||||
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
|
||||
// Plates progress: archive_count / target_count
|
||||
const platesProgressPercent = project.target_count
|
||||
? Math.round((project.archive_count / project.target_count) * 100)
|
||||
@@ -300,7 +305,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark text-bambu-gray'
|
||||
}`}>
|
||||
{project.completed_count}/{project.target_parts_count} parts
|
||||
{project.completed_count}/{project.target_parts_count} {t('projects.parts')}
|
||||
</span>
|
||||
) : project.target_count ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
|
||||
@@ -308,21 +313,21 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark text-bambu-gray'
|
||||
}`}>
|
||||
{project.archive_count}/{project.target_count} plates
|
||||
{project.archive_count}/{project.target_count} {t('projects.plates')}
|
||||
</span>
|
||||
) : project.completed_count > 0 ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
|
||||
{project.completed_count} parts
|
||||
{project.completed_count} {t('projects.parts')}
|
||||
</span>
|
||||
) : null}
|
||||
{isCompleted && (
|
||||
<span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
Done
|
||||
{t('projects.done')}
|
||||
</span>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
Archived
|
||||
{t('projects.statusArchived')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -396,10 +401,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
}`}
|
||||
onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
|
||||
disabled={!hasPermission('projects:update')}
|
||||
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
|
||||
title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
|
||||
@@ -407,10 +412,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
}`}
|
||||
onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
|
||||
disabled={!hasPermission('projects:delete')}
|
||||
title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
|
||||
title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -426,7 +431,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
{project.target_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-bambu-gray">Plates</span>
|
||||
<span className="text-bambu-gray">{t('projects.plates')}</span>
|
||||
<span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
|
||||
{project.archive_count} / {project.target_count}
|
||||
</span>
|
||||
@@ -449,7 +454,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
{project.target_parts_count && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-bambu-gray">Parts</span>
|
||||
<span className="text-bambu-gray">{t('projects.parts')}</span>
|
||||
<span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
|
||||
{project.completed_count} / {project.target_parts_count}
|
||||
</span>
|
||||
@@ -471,7 +476,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
{/* Failed count */}
|
||||
{project.failed_count > 0 && (
|
||||
<div className="text-xs text-red-400">
|
||||
{project.failed_count} failed
|
||||
{project.failed_count} {t('projects.failed')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -480,25 +485,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
{project.completed_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray">
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
<span>{project.completed_count} completed</span>
|
||||
<span>{project.completed_count} {t('projects.completed')}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.failed_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span>{project.failed_count} failed</span>
|
||||
<span>{project.failed_count} {t('projects.failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.queue_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-blue-400">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{project.queue_count} in queue</span>
|
||||
<span>{project.queue_count} {t('projects.inQueue')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-bambu-gray/60 italic">
|
||||
No prints yet
|
||||
{t('projects.noPrintsYet')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -534,7 +539,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
</div>
|
||||
{project.archive_count > 4 && (
|
||||
<p className="text-xs text-bambu-gray mt-1.5 text-center">
|
||||
+{project.archive_count - 4} more
|
||||
{t('common.more', { count: project.archive_count - 4 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -543,22 +548,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
{/* Stats footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-4 text-xs text-bambu-gray">
|
||||
<div className="flex items-center gap-1.5" title="Print jobs (plates)">
|
||||
<div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
|
||||
<Layers className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{project.archive_count} plates</span>
|
||||
<span>{project.archive_count} {t('projects.plates')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="Parts printed">
|
||||
<div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
|
||||
<Package className="w-3.5 h-3.5 text-bambu-green" />
|
||||
<span>{project.completed_count} parts</span>
|
||||
<span>{project.completed_count} {t('projects.parts')}</span>
|
||||
</div>
|
||||
{project.failed_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
|
||||
<div className="flex items-center gap-1.5 text-red-400" title={t('projects.failedParts')}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span>{project.failed_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.queue_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
|
||||
<div className="flex items-center gap-1.5 text-yellow-400" title={t('projects.inQueue')}>
|
||||
<ListTodo className="w-3.5 h-3.5" />
|
||||
<span>{project.queue_count}</span>
|
||||
</div>
|
||||
@@ -572,6 +577,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
|
||||
}
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
@@ -591,7 +597,7 @@ export function ProjectsPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
setShowModal(false);
|
||||
showToast('Project created', 'success');
|
||||
showToast(t('projects.toast.created'), 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -605,7 +611,7 @@ export function ProjectsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
setShowModal(false);
|
||||
setEditingProject(undefined);
|
||||
showToast('Project updated', 'success');
|
||||
showToast(t('projects.toast.updated'), 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -616,7 +622,7 @@ export function ProjectsPage() {
|
||||
mutationFn: (id: number) => api.deleteProject(id),
|
||||
onSuccess: () => {
|
||||
setDeleteConfirm(null);
|
||||
showToast('Project deleted', 'success');
|
||||
showToast(t('projects.toast.deleted'), 'success');
|
||||
// Reload to refresh the list (React Query cache invalidation not working reliably)
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
},
|
||||
@@ -630,7 +636,7 @@ export function ProjectsPage() {
|
||||
mutationFn: (data: ProjectImport) => api.importProject(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast('Project imported', 'success');
|
||||
showToast(t('projects.toast.imported'), 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -656,7 +662,7 @@ export function ProjectsPage() {
|
||||
a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Projects exported (metadata only)', 'success');
|
||||
showToast(t('projects.toast.exported'), 'success');
|
||||
} catch (error) {
|
||||
showToast((error as Error).message, 'error');
|
||||
}
|
||||
@@ -689,7 +695,7 @@ export function ProjectsPage() {
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast('Project imported', 'success');
|
||||
showToast(t('projects.toast.imported'), 'success');
|
||||
} else {
|
||||
// JSON file: parse and handle bulk or single import
|
||||
const text = await file.text();
|
||||
@@ -703,11 +709,11 @@ export function ProjectsPage() {
|
||||
}
|
||||
|
||||
if (projectsToImport.length > 1) {
|
||||
showToast(`${projectsToImport.length} projects imported`, 'success');
|
||||
showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`Import failed: ${(error as Error).message}`, 'error');
|
||||
showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
@@ -767,10 +773,10 @@ export function ProjectsPage() {
|
||||
<div className="p-2.5 bg-bambu-green/10 rounded-xl">
|
||||
<FolderKanban className="w-6 h-6 text-bambu-green" />
|
||||
</div>
|
||||
Projects
|
||||
{t('projects.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-bambu-gray mt-2 ml-14">
|
||||
Organize and track your 3D printing projects
|
||||
{t('projects.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -778,28 +784,28 @@ export function ProjectsPage() {
|
||||
variant="secondary"
|
||||
onClick={handleImportClick}
|
||||
disabled={!hasPermission('projects:create')}
|
||||
title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
|
||||
title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import
|
||||
{t('projects.import')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExportAll}
|
||||
disabled={!hasPermission('projects:read')}
|
||||
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
|
||||
title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
{t('projects.export')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="sm:w-auto w-full"
|
||||
disabled={!hasPermission('projects:create')}
|
||||
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
|
||||
title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
{t('projects.newProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -807,10 +813,10 @@ export function ProjectsPage() {
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
|
||||
{[
|
||||
{ key: 'active', label: 'Active', icon: Clock },
|
||||
{ key: 'completed', label: 'Completed', icon: CheckCircle2 },
|
||||
{ key: 'archived', label: 'Archived', icon: Archive },
|
||||
{ key: 'all', label: 'All', icon: FolderKanban },
|
||||
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
|
||||
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
|
||||
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
|
||||
{ key: 'all', label: t('common.all'), icon: FolderKanban },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -839,7 +845,7 @@ export function ProjectsPage() {
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
<p className="text-sm text-bambu-gray">Loading projects...</p>
|
||||
<p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : projects?.length === 0 ? (
|
||||
@@ -848,22 +854,22 @@ export function ProjectsPage() {
|
||||
<FolderKanban className="w-12 h-12 text-bambu-gray/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">
|
||||
{statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
|
||||
{statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
|
||||
</h3>
|
||||
<p className="text-bambu-gray text-center max-w-md mb-6">
|
||||
{statusFilter === 'all'
|
||||
? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
|
||||
: `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
|
||||
? t('projects.createFirst')
|
||||
: t('projects.noProjectsFilteredHelp', { status: statusFilter })
|
||||
}
|
||||
</p>
|
||||
{statusFilter === 'all' && (
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
disabled={!hasPermission('projects:create')}
|
||||
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
|
||||
title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Your First Project
|
||||
{t('projects.createFirstButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -877,6 +883,7 @@ export function ProjectsPage() {
|
||||
onEdit={() => handleEdit(project)}
|
||||
onDelete={() => handleDeleteClick(project.id)}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -885,9 +892,9 @@ export function ProjectsPage() {
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirm !== null && (
|
||||
<ConfirmModal
|
||||
title="Delete Project"
|
||||
message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
|
||||
confirmText="Delete Project"
|
||||
title={t('projects.deleteProject')}
|
||||
message={t('projects.deleteConfirm')}
|
||||
confirmText={t('projects.deleteProject')}
|
||||
variant="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
@@ -904,6 +911,7 @@ export function ProjectsPage() {
|
||||
}}
|
||||
onSave={handleSave}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+135
-124
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
@@ -64,39 +65,39 @@ function formatDuration(seconds: number | null | undefined): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
|
||||
if (!dateString) return 'ASAP';
|
||||
function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
|
||||
const date = parseUTCDate(dateString);
|
||||
if (!date) return 'ASAP';
|
||||
if (!date) return t?.('queue.time.asap') ?? 'ASAP';
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < -60000) return 'Overdue';
|
||||
if (diff < 0) return 'Now';
|
||||
if (diff < 60000) return 'In less than a minute';
|
||||
if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
|
||||
if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
|
||||
if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
|
||||
if (diff < 0) return t?.('queue.time.now') ?? 'Now';
|
||||
if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
|
||||
if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
|
||||
if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
|
||||
return formatDateTime(dateString, timeFormat);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
|
||||
function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
|
||||
// Special case: pending with waiting_reason shows as "Waiting"
|
||||
if (status === 'pending' && waitingReason) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Waiting
|
||||
{t('queue.status.waiting')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
|
||||
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
|
||||
completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: 'Completed' },
|
||||
failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: 'Failed' },
|
||||
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
|
||||
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
|
||||
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
|
||||
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
|
||||
completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
|
||||
failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
|
||||
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
|
||||
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
|
||||
};
|
||||
|
||||
const { icon: Icon, color, label } = config[status];
|
||||
@@ -116,12 +117,14 @@ function BulkEditModal({
|
||||
onSave,
|
||||
onClose,
|
||||
isSaving,
|
||||
t,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
printers: { id: number; name: string }[];
|
||||
onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
|
||||
onClose: () => void;
|
||||
isSaving: boolean;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
|
||||
const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
|
||||
@@ -158,7 +161,7 @@ function BulkEditModal({
|
||||
<div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Edit {selectedCount} Item{selectedCount !== 1 ? 's' : ''}
|
||||
{t('queue.bulkEdit.title', { count: selectedCount })}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
|
||||
<X className="w-5 h-5 text-bambu-gray" />
|
||||
@@ -167,12 +170,12 @@ function BulkEditModal({
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Only changed settings will be applied to selected items.
|
||||
{t('queue.bulkEdit.description')}
|
||||
</p>
|
||||
|
||||
{/* Printer Assignment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">Printer</label>
|
||||
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
|
||||
<select
|
||||
value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
|
||||
onChange={(e) => {
|
||||
@@ -183,8 +186,8 @@ function BulkEditModal({
|
||||
}}
|
||||
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="unchanged">— No change —</option>
|
||||
<option value="null">Unassigned</option>
|
||||
<option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
|
||||
<option value="null">{t('queue.filter.unassigned')}</option>
|
||||
{printers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
@@ -193,35 +196,35 @@ function BulkEditModal({
|
||||
|
||||
{/* Queue Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">Queue Options</label>
|
||||
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
|
||||
<div className="space-y-2">
|
||||
<TriStateToggle label="Staged (manual start)" value={manualStart} onChange={setManualStart} />
|
||||
<TriStateToggle label="Auto power off after print" value={autoOffAfter} onChange={setAutoOffAfter} />
|
||||
<TriStateToggle label="Require previous success" value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">Print Options</label>
|
||||
<label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
|
||||
<div className="space-y-2">
|
||||
<TriStateToggle label="Bed levelling" value={bedLevelling} onChange={setBedLevelling} />
|
||||
<TriStateToggle label="Flow calibration" value={flowCali} onChange={setFlowCali} />
|
||||
<TriStateToggle label="Vibration calibration" value={vibrationCali} onChange={setVibrationCali} />
|
||||
<TriStateToggle label="First layer inspection" value={layerInspect} onChange={setLayerInspect} />
|
||||
<TriStateToggle label="Timelapse" value={timelapse} onChange={setTimelapse} />
|
||||
<TriStateToggle label="Use AMS" value={useAms} onChange={setUseAms} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
|
||||
<TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Apply Changes'}
|
||||
{isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,10 +237,12 @@ function TriStateToggle({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
label: string;
|
||||
value: boolean | 'unchanged';
|
||||
onChange: (val: boolean | 'unchanged') => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
@@ -253,13 +258,13 @@ function TriStateToggle({
|
||||
onClick={() => onChange(false)}
|
||||
className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'}`}
|
||||
>
|
||||
Off
|
||||
{t('common.off')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange(true)}
|
||||
className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'}`}
|
||||
>
|
||||
On
|
||||
{t('common.on')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,6 +286,7 @@ function SortableQueueItem({
|
||||
onToggleSelect,
|
||||
hasPermission,
|
||||
canModify,
|
||||
t,
|
||||
}: {
|
||||
item: PrintQueueItem;
|
||||
position?: number;
|
||||
@@ -295,6 +301,7 @@ function SortableQueueItem({
|
||||
onToggleSelect?: () => void;
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const canReorder = hasPermission('queue:reorder');
|
||||
const {
|
||||
@@ -400,7 +407,7 @@ function SortableQueueItem({
|
||||
<Link
|
||||
to={`/archives?highlight=${item.archive_id}`}
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
|
||||
title="View archive"
|
||||
title={t('queue.viewArchive')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
@@ -408,7 +415,7 @@ function SortableQueueItem({
|
||||
<Link
|
||||
to={`/library?highlight=${item.library_file_id}`}
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
|
||||
title="View in File Manager"
|
||||
title={t('queue.viewInFileManager')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
@@ -419,10 +426,10 @@ function SortableQueueItem({
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
{item.target_model
|
||||
? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
|
||||
? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
|
||||
: item.printer_id === null
|
||||
? 'Unassigned'
|
||||
: (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
? t('queue.filter.unassigned')
|
||||
: (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
|
||||
</span>
|
||||
{item.print_time_seconds && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -431,7 +438,7 @@ function SortableQueueItem({
|
||||
</span>
|
||||
)}
|
||||
{item.created_by_username && (
|
||||
<span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
|
||||
<span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
{item.created_by_username}
|
||||
</span>
|
||||
@@ -439,7 +446,7 @@ function SortableQueueItem({
|
||||
{isPending && !item.manual_start && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatRelativeTime(item.scheduled_time, timeFormat)}
|
||||
{formatRelativeTime(item.scheduled_time, timeFormat, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -449,18 +456,18 @@ function SortableQueueItem({
|
||||
{item.manual_start && (
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
|
||||
<Hand className="w-3 h-3" />
|
||||
Staged
|
||||
{t('queue.badges.staged')}
|
||||
</span>
|
||||
)}
|
||||
{item.require_previous_success && (
|
||||
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
|
||||
Requires previous success
|
||||
{t('queue.badges.requiresPrevious')}
|
||||
</span>
|
||||
)}
|
||||
{item.auto_off_after && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
|
||||
<Power className="w-3 h-3" />
|
||||
Auto power off
|
||||
{t('queue.badges.autoPowerOff')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -471,7 +478,7 @@ function SortableQueueItem({
|
||||
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -493,7 +500,7 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
|
||||
<StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -503,7 +510,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onStop}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? 'You do not have permission to stop prints' : 'Stop Print'}
|
||||
title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<StopCircle className="w-4 h-4" />
|
||||
@@ -517,7 +524,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onStart}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? 'You do not have permission to start prints' : 'Start Print'}
|
||||
title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
|
||||
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
@@ -528,7 +535,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={!canModify('queue', 'update', item.created_by_id)}
|
||||
title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
|
||||
title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -537,7 +544,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={!canModify('queue', 'delete', item.created_by_id)}
|
||||
title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'}
|
||||
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -551,7 +558,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onRequeue}
|
||||
disabled={!hasPermission('queue:create')}
|
||||
title={!hasPermission('queue:create') ? 'You do not have permission to re-queue items' : 'Re-queue'}
|
||||
title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
|
||||
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
@@ -561,7 +568,7 @@ function SortableQueueItem({
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={!canModify('queue', 'delete', item.created_by_id)}
|
||||
title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'}
|
||||
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -574,6 +581,7 @@ function SortableQueueItem({
|
||||
}
|
||||
|
||||
export function QueuePage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission, hasAnyPermission, canModify } = useAuth();
|
||||
@@ -650,36 +658,36 @@ export function QueuePage() {
|
||||
mutationFn: (id: number) => api.cancelQueueItem(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast('Queue item cancelled');
|
||||
showToast(t('queue.toast.cancelled'));
|
||||
},
|
||||
onError: () => showToast('Failed to cancel item', 'error'),
|
||||
onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: number) => api.removeFromQueue(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast('Queue item removed');
|
||||
showToast(t('queue.toast.removed'));
|
||||
},
|
||||
onError: () => showToast('Failed to remove item', 'error'),
|
||||
onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: (id: number) => api.stopQueueItem(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast('Print stopped');
|
||||
showToast(t('queue.toast.stopped'));
|
||||
},
|
||||
onError: () => showToast('Failed to stop print', 'error'),
|
||||
onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (id: number) => api.startQueueItem(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast('Print released to queue');
|
||||
showToast(t('queue.toast.released'));
|
||||
},
|
||||
onError: () => showToast('Failed to start print', 'error'),
|
||||
onError: () => showToast(t('queue.toast.startFailed'), 'error'),
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
@@ -687,7 +695,7 @@ export function QueuePage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
},
|
||||
onError: () => showToast('Failed to reorder queue', 'error'),
|
||||
onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
|
||||
});
|
||||
|
||||
const clearHistoryMutation = useMutation({
|
||||
@@ -702,9 +710,9 @@ export function QueuePage() {
|
||||
},
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
|
||||
showToast(t('queue.toast.historyCleared', { count }));
|
||||
},
|
||||
onError: () => showToast('Failed to clear history', 'error'),
|
||||
onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
|
||||
});
|
||||
|
||||
const bulkUpdateMutation = useMutation({
|
||||
@@ -715,7 +723,7 @@ export function QueuePage() {
|
||||
setShowBulkEditModal(false);
|
||||
showToast(result.message);
|
||||
},
|
||||
onError: () => showToast('Failed to update items', 'error'),
|
||||
onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
|
||||
});
|
||||
|
||||
const bulkCancelMutation = useMutation({
|
||||
@@ -728,9 +736,9 @@ export function QueuePage() {
|
||||
onSuccess: (count) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
setSelectedItems([]);
|
||||
showToast(`Cancelled ${count} item${count !== 1 ? 's' : ''}`);
|
||||
showToast(t('queue.toast.bulkCancelled', { count }));
|
||||
},
|
||||
onError: () => showToast('Failed to cancel items', 'error'),
|
||||
onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
|
||||
});
|
||||
|
||||
const handleToggleSelect = (id: number) => {
|
||||
@@ -867,9 +875,9 @@ export function QueuePage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<ListOrdered className="w-7 h-7 text-bambu-green" />
|
||||
Print Queue
|
||||
{t('queue.title')}
|
||||
</h1>
|
||||
<p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
|
||||
<p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -883,7 +891,7 @@ export function QueuePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{activeItems.length}</p>
|
||||
<p className="text-sm text-bambu-gray">Printing</p>
|
||||
<p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -897,7 +905,7 @@ export function QueuePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{pendingItems.length}</p>
|
||||
<p className="text-sm text-bambu-gray">Queued</p>
|
||||
<p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -911,7 +919,7 @@ export function QueuePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
|
||||
<p className="text-sm text-bambu-gray">Total Queue Time</p>
|
||||
<p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -925,7 +933,7 @@ export function QueuePage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{historyItems.length}</p>
|
||||
<p className="text-sm text-bambu-gray">History</p>
|
||||
<p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -944,8 +952,8 @@ export function QueuePage() {
|
||||
else setFilterPrinter(Number(val));
|
||||
}}
|
||||
>
|
||||
<option value="">All Printers</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
<option value="">{t('queue.filter.allPrinters')}</option>
|
||||
<option value="unassigned">{t('queue.filter.unassigned')}</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
@@ -956,13 +964,13 @@ export function QueuePage() {
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="printing">Printing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="">{t('queue.filter.allStatus')}</option>
|
||||
<option value="pending">{t('queue.status.pending')}</option>
|
||||
<option value="printing">{t('queue.status.printing')}</option>
|
||||
<option value="completed">{t('queue.status.completed')}</option>
|
||||
<option value="failed">{t('queue.status.failed')}</option>
|
||||
<option value="skipped">{t('queue.status.skipped')}</option>
|
||||
<option value="cancelled">{t('queue.status.cancelled')}</option>
|
||||
</select>
|
||||
|
||||
{uniqueLocations.length > 0 && (
|
||||
@@ -971,7 +979,7 @@ export function QueuePage() {
|
||||
value={filterLocation}
|
||||
onChange={(e) => setFilterLocation(e.target.value)}
|
||||
>
|
||||
<option value="">All Locations</option>
|
||||
<option value="">{t('queue.filter.allLocations')}</option>
|
||||
{uniqueLocations.map((loc) => (
|
||||
<option key={loc} value={loc}>{loc}</option>
|
||||
))}
|
||||
@@ -986,23 +994,22 @@ export function QueuePage() {
|
||||
size="sm"
|
||||
onClick={() => setShowClearHistoryConfirm(true)}
|
||||
disabled={!hasPermission('queue:delete_all')}
|
||||
title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined}
|
||||
title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear History
|
||||
{t('queue.clearHistory')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-bambu-gray">Loading...</div>
|
||||
<div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
|
||||
) : queue?.length === 0 ? (
|
||||
<Card className="p-12 text-center border-dashed">
|
||||
<Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
|
||||
<h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
|
||||
<p className="text-bambu-gray max-w-md mx-auto">
|
||||
Schedule a print from the Archives page using the "Schedule" option in the context menu,
|
||||
or drag and drop files to get started.
|
||||
{t('queue.empty.description')}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -1012,7 +1019,7 @@ export function QueuePage() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Currently Printing
|
||||
{t('queue.sections.currentlyPrinting')}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{activeItems.map((item) => (
|
||||
@@ -1028,6 +1035,7 @@ export function QueuePage() {
|
||||
timeFormat={timeFormat}
|
||||
hasPermission={hasPermission}
|
||||
canModify={canModify}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1040,12 +1048,12 @@ export function QueuePage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
Queued
|
||||
{t('queue.sections.queued')}
|
||||
<span className="text-sm font-normal text-bambu-gray">
|
||||
({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
|
||||
({t('queue.itemCount', { count: pendingItems.length })})
|
||||
</span>
|
||||
<span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
|
||||
Drag to reorder (ASAP only)
|
||||
<span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
|
||||
{t('queue.dragToReorder')}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1054,16 +1062,16 @@ export function QueuePage() {
|
||||
value={pendingSortBy}
|
||||
onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
|
||||
>
|
||||
<option value="position">Sort by Position</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="printer">Sort by Printer</option>
|
||||
<option value="time">Sort by Schedule</option>
|
||||
<option value="position">{t('queue.sort.byPosition')}</option>
|
||||
<option value="name">{t('queue.sort.byName')}</option>
|
||||
<option value="printer">{t('queue.sort.byPrinter')}</option>
|
||||
<option value="time">{t('queue.sort.bySchedule')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPendingSortAsc(!pendingSortAsc)}
|
||||
title={pendingSortAsc ? 'Ascending' : 'Descending'}
|
||||
title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
|
||||
className="px-2"
|
||||
>
|
||||
{pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
@@ -1084,12 +1092,12 @@ export function QueuePage() {
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
{selectedItems.length === pendingItems.length && pendingItems.length > 0 ? 'Deselect All' : 'Select All'}
|
||||
{selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
|
||||
</Button>
|
||||
{selectedItems.length > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{selectedItems.length} selected
|
||||
{t('queue.bulkEdit.selected', { count: selectedItems.length })}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-bambu-dark-tertiary" />
|
||||
<Button
|
||||
@@ -1098,10 +1106,10 @@ export function QueuePage() {
|
||||
onClick={() => setShowBulkEditModal(true)}
|
||||
className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
|
||||
disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
|
||||
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined}
|
||||
title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit Selected
|
||||
{t('queue.bulkEdit.editSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1109,10 +1117,10 @@ export function QueuePage() {
|
||||
onClick={() => bulkCancelMutation.mutate(selectedItems)}
|
||||
className="flex items-center gap-2 text-red-400 hover:text-red-300"
|
||||
disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
|
||||
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined}
|
||||
title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancel Selected
|
||||
{t('queue.bulkEdit.cancelSelected')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1144,6 +1152,7 @@ export function QueuePage() {
|
||||
onToggleSelect={() => handleToggleSelect(item.id)}
|
||||
hasPermission={hasPermission}
|
||||
canModify={canModify}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1158,9 +1167,9 @@ export function QueuePage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-bambu-gray" />
|
||||
History
|
||||
{t('queue.sections.history')}
|
||||
<span className="text-sm font-normal text-bambu-gray">
|
||||
({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
|
||||
({t('queue.itemCount', { count: historyItems.length })})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1169,15 +1178,15 @@ export function QueuePage() {
|
||||
value={historySortBy}
|
||||
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="printer">Sort by Printer</option>
|
||||
<option value="date">{t('queue.sort.byDate')}</option>
|
||||
<option value="name">{t('queue.sort.byName')}</option>
|
||||
<option value="printer">{t('queue.sort.byPrinter')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setHistorySortAsc(!historySortAsc)}
|
||||
title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
|
||||
title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
|
||||
className="px-2"
|
||||
>
|
||||
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
@@ -1199,6 +1208,7 @@ export function QueuePage() {
|
||||
timeFormat={timeFormat}
|
||||
hasPermission={hasPermission}
|
||||
canModify={canModify}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1234,21 +1244,21 @@ export function QueuePage() {
|
||||
{confirmAction && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
|
||||
confirmAction.type === 'stop' ? 'Stop Print' :
|
||||
'Remove from History'
|
||||
confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
|
||||
confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
|
||||
t('queue.confirm.removeTitle')
|
||||
}
|
||||
message={
|
||||
confirmAction.type === 'cancel'
|
||||
? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
|
||||
? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
|
||||
: confirmAction.type === 'stop'
|
||||
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
|
||||
: `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
|
||||
? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
|
||||
: t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
|
||||
}
|
||||
confirmText={
|
||||
confirmAction.type === 'cancel' ? 'Cancel Print' :
|
||||
confirmAction.type === 'stop' ? 'Stop Print' :
|
||||
'Remove'
|
||||
confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
|
||||
confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
|
||||
t('common.remove')
|
||||
}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
@@ -1268,9 +1278,9 @@ export function QueuePage() {
|
||||
{/* Clear History Confirm Modal */}
|
||||
{showClearHistoryConfirm && (
|
||||
<ConfirmModal
|
||||
title="Clear History"
|
||||
message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
|
||||
confirmText="Clear History"
|
||||
title={t('queue.confirm.clearHistoryTitle')}
|
||||
message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
|
||||
confirmText={t('queue.clearHistory')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
clearHistoryMutation.mutate();
|
||||
@@ -1292,6 +1302,7 @@ export function QueuePage() {
|
||||
}}
|
||||
onClose={() => setShowBulkEditModal(false)}
|
||||
isSaving={bulkUpdateMutation.isPending}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+238
-237
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -9,6 +10,7 @@ import { Info } from 'lucide-react';
|
||||
|
||||
export function SetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
const { refreshAuth } = useAuth();
|
||||
@@ -30,14 +32,14 @@ export function SetupPage() {
|
||||
|
||||
if (data.auth_enabled) {
|
||||
if (data.admin_created) {
|
||||
showToast('Authentication enabled and admin user created');
|
||||
showToast(t('setup.toast.authEnabledAdminCreated'));
|
||||
navigate('/login');
|
||||
} else {
|
||||
showToast('Authentication enabled using existing admin users');
|
||||
showToast(t('setup.toast.authEnabledExistingAdmins'));
|
||||
navigate('/login');
|
||||
}
|
||||
} else {
|
||||
showToast('Setup completed');
|
||||
showToast(t('setup.toast.setupCompleted'));
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
@@ -54,15 +56,15 @@ export function SetupPage() {
|
||||
// If no credentials provided, backend will use existing admin users if they exist
|
||||
if (adminUsername || adminPassword) {
|
||||
if (!adminUsername || !adminPassword) {
|
||||
showToast('Please enter both admin username and password, or leave both empty to use existing admin users', 'error');
|
||||
showToast(t('setup.toast.enterBothCredentials'), 'error');
|
||||
return;
|
||||
}
|
||||
if (adminPassword !== confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
showToast(t('setup.toast.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (adminPassword.length < 6) {
|
||||
showToast('Password must be at least 6 characters', 'error');
|
||||
showToast(t('setup.toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -83,10 +85,10 @@ export function SetupPage() {
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white">
|
||||
Bambuddy Setup
|
||||
{t('setup.title')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">
|
||||
Configure authentication for your Bambuddy instance
|
||||
{t('setup.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -101,7 +103,7 @@ export function SetupPage() {
|
||||
className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary"
|
||||
/>
|
||||
<label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
|
||||
Enable Authentication
|
||||
{t('setup.enableAuth')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -111,10 +113,9 @@ export function SetupPage() {
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-bambu-gray">
|
||||
<p className="text-white font-medium mb-1">Admin Account</p>
|
||||
<p className="text-white font-medium mb-1">{t('setup.adminAccount')}</p>
|
||||
<p>
|
||||
If admin users already exist, authentication will be enabled using the existing admin accounts.
|
||||
Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.
|
||||
{t('setup.adminAccountDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,7 +123,7 @@ export function SetupPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
|
||||
Admin Username <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
|
||||
{t('setup.adminUsername')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
@@ -130,14 +131,14 @@ export function SetupPage() {
|
||||
value={adminUsername}
|
||||
onChange={(e) => setAdminUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter admin username (optional)"
|
||||
placeholder={t('setup.adminUsernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
|
||||
Admin Password <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
|
||||
{t('setup.adminPassword')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
@@ -145,7 +146,7 @@ export function SetupPage() {
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter admin password (optional)"
|
||||
placeholder={t('setup.adminPasswordPlaceholder')}
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
@@ -154,7 +155,7 @@ export function SetupPage() {
|
||||
{adminPassword && (
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
|
||||
Confirm Password
|
||||
{t('setup.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
@@ -162,7 +163,7 @@ export function SetupPage() {
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Confirm admin password"
|
||||
placeholder={t('setup.confirmPasswordPlaceholder')}
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
@@ -178,7 +179,7 @@ export function SetupPage() {
|
||||
disabled={setupMutation.isPending}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
|
||||
>
|
||||
{setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
|
||||
{setupMutation.isPending ? t('setup.settingUp') : t('setup.completeSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Package,
|
||||
Clock,
|
||||
@@ -30,6 +31,7 @@ import { Dashboard, type DashboardWidget } from '../components/Dashboard';
|
||||
function QuickStatsWidget({
|
||||
stats,
|
||||
currency,
|
||||
t,
|
||||
}: {
|
||||
stats: {
|
||||
total_prints: number;
|
||||
@@ -42,6 +44,7 @@ function QuickStatsWidget({
|
||||
total_energy_cost: number;
|
||||
} | undefined;
|
||||
currency: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
@@ -50,7 +53,7 @@ function QuickStatsWidget({
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Total Prints</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +62,7 @@ function QuickStatsWidget({
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Print Time</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +71,7 @@ function QuickStatsWidget({
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Filament Used</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
|
||||
<p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +80,7 @@ function QuickStatsWidget({
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Filament Cost</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
|
||||
<p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +89,7 @@ function QuickStatsWidget({
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Energy Used</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
|
||||
<p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +98,7 @@ function QuickStatsWidget({
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray">Energy Cost</p>
|
||||
<p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
|
||||
<p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +110,7 @@ function SuccessRateWidget({
|
||||
stats,
|
||||
printerMap,
|
||||
size = 1,
|
||||
t,
|
||||
}: {
|
||||
stats: {
|
||||
total_prints: number;
|
||||
@@ -116,6 +120,7 @@ function SuccessRateWidget({
|
||||
} | undefined;
|
||||
printerMap: Map<string, string>;
|
||||
size?: 1 | 2 | 4;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const successRate = stats?.total_prints
|
||||
? Math.round((stats.successful_prints / stats.total_prints) * 100)
|
||||
@@ -157,24 +162,24 @@ function SuccessRateWidget({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-status-ok flex-shrink-0" />
|
||||
<span className="text-sm text-bambu-gray">Successful:</span>
|
||||
<span className="text-sm text-bambu-gray">{t('stats.successful')}</span>
|
||||
<span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-status-error flex-shrink-0" />
|
||||
<span className="text-sm text-bambu-gray">Failed:</span>
|
||||
<span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
|
||||
<span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show per-printer breakdown when expanded */}
|
||||
{size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray font-medium mb-2">Prints by Printer</p>
|
||||
<p className="text-xs text-bambu-gray font-medium mb-2">{t('stats.printsByPrinter')}</p>
|
||||
<div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
|
||||
{Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
|
||||
<div key={printerId} className="flex items-center gap-3 text-sm">
|
||||
<span className="text-bambu-gray truncate max-w-[120px]">
|
||||
{printerMap.get(printerId) || `Printer ${printerId}`}
|
||||
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
|
||||
</span>
|
||||
<span className="text-white font-medium">{count}</span>
|
||||
</div>
|
||||
@@ -191,6 +196,7 @@ function TimeAccuracyWidget({
|
||||
stats,
|
||||
printerMap,
|
||||
size = 1,
|
||||
t,
|
||||
}: {
|
||||
stats: {
|
||||
average_time_accuracy: number | null;
|
||||
@@ -198,13 +204,14 @@ function TimeAccuracyWidget({
|
||||
} | undefined;
|
||||
printerMap: Map<string, string>;
|
||||
size?: 1 | 2 | 4;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const accuracy = stats?.average_time_accuracy;
|
||||
|
||||
if (accuracy === null || accuracy === undefined) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
|
||||
<p className="text-bambu-gray text-center py-4">{t('stats.noTimeAccuracyData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,14 +274,14 @@ function TimeAccuracyWidget({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-bambu-gray">
|
||||
<Target className="w-3 h-3 flex-shrink-0" />
|
||||
<span>100% = perfect estimate</span>
|
||||
<span>{t('stats.perfectEstimate')}</span>
|
||||
</div>
|
||||
{printerEntries.length > 0 && (
|
||||
<div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
|
||||
{printerEntries.map(([printerId, acc]) => (
|
||||
<div key={printerId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-bambu-gray truncate max-w-[100px]">
|
||||
{printerMap.get(printerId) || `Printer ${printerId}`}
|
||||
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
acc >= 95 && acc <= 105 ? 'text-status-ok' :
|
||||
@@ -294,15 +301,17 @@ function TimeAccuracyWidget({
|
||||
function FilamentTypesWidget({
|
||||
stats,
|
||||
size = 1,
|
||||
t,
|
||||
}: {
|
||||
stats: {
|
||||
total_prints: number;
|
||||
prints_by_filament_type: Record<string, number>;
|
||||
} | undefined;
|
||||
size?: 1 | 2 | 4;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
|
||||
return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
|
||||
return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
|
||||
}
|
||||
|
||||
// Sort by print count descending
|
||||
@@ -348,7 +357,7 @@ function FilamentTypesWidget({
|
||||
<div key={type}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-white">{type}</span>
|
||||
<span className="text-bambu-gray">{count} prints</span>
|
||||
<span className="text-bambu-gray">{count} {t('common.prints')}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bambu-dark rounded-full">
|
||||
<div
|
||||
@@ -361,7 +370,7 @@ function FilamentTypesWidget({
|
||||
})}
|
||||
{hasMore && (
|
||||
<p className="text-xs text-bambu-gray text-center pt-1">
|
||||
+{sortedEntries.length - maxEntries} more types
|
||||
{t('common.more', { count: sortedEntries.length - maxEntries })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -383,12 +392,14 @@ function PrintActivityWidget({
|
||||
function PrintsByPrinterWidget({
|
||||
stats,
|
||||
printerMap,
|
||||
t,
|
||||
}: {
|
||||
stats: { prints_by_printer: Record<string, number> } | undefined;
|
||||
printerMap: Map<string, string>;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
|
||||
return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
|
||||
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -400,9 +411,9 @@ function PrintsByPrinterWidget({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{printerMap.get(printerId) || `Printer ${printerId}`}
|
||||
{printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">{count} prints</p>
|
||||
<p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -413,17 +424,19 @@ function PrintsByPrinterWidget({
|
||||
function FilamentTrendsWidget({
|
||||
archives,
|
||||
currency,
|
||||
t,
|
||||
}: {
|
||||
archives: Parameters<typeof FilamentTrends>[0]['archives'];
|
||||
currency: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (!archives || archives.length === 0) {
|
||||
return <p className="text-bambu-gray text-center py-4">No print data available</p>;
|
||||
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
|
||||
}
|
||||
return <FilamentTrends archives={archives} currency={currency} />;
|
||||
}
|
||||
|
||||
function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
|
||||
const { data: analysis, isLoading } = useQuery({
|
||||
queryKey: ['failureAnalysis'],
|
||||
queryFn: () => api.getFailureAnalysis({ days: 30 }),
|
||||
@@ -438,7 +451,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
}
|
||||
|
||||
if (!analysis || analysis.total_prints === 0) {
|
||||
return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
|
||||
return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
|
||||
}
|
||||
|
||||
// Show more reasons when expanded
|
||||
@@ -458,7 +471,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-bambu-gray mt-1">
|
||||
{analysis.failed_prints} / {analysis.total_prints} prints failed
|
||||
{t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
|
||||
</div>
|
||||
{/* Trend indicator */}
|
||||
{analysis.trend && analysis.trend.length >= 2 && (
|
||||
@@ -470,7 +483,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
: 'text-status-error'
|
||||
}`} />
|
||||
<span className="text-bambu-gray">
|
||||
Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
|
||||
{t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,13 +494,13 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
{topReasons.length > 0 && (
|
||||
<div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
|
||||
<p className="text-xs text-bambu-gray font-medium mb-2">
|
||||
{size >= 2 ? 'Failure Reasons' : 'Top Failure Reasons'}
|
||||
{size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}
|
||||
</p>
|
||||
<div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
|
||||
{topReasons.map(([reason, count]) => (
|
||||
<div key={reason} className="flex items-center justify-between text-sm">
|
||||
<span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
|
||||
{reason || 'Unknown'}
|
||||
{reason || t('common.unknown')}
|
||||
</span>
|
||||
<span className="text-bambu-gray ml-2">{count}</span>
|
||||
</div>
|
||||
@@ -495,7 +508,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
</div>
|
||||
{hasMore && (
|
||||
<p className="text-xs text-bambu-gray mt-2">
|
||||
+{allReasons.length - maxReasons} more reasons
|
||||
{t('common.more', { count: allReasons.length - maxReasons })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -505,6 +518,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
|
||||
}
|
||||
|
||||
export function StatsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -568,9 +582,9 @@ export function StatsPage() {
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Export downloaded');
|
||||
showToast(t('stats.exportDownloaded'));
|
||||
} catch {
|
||||
showToast('Export failed', 'error');
|
||||
showToast(t('stats.exportFailed'), 'error');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
@@ -581,9 +595,9 @@ export function StatsPage() {
|
||||
try {
|
||||
const result = await api.recalculateCosts();
|
||||
await refetchStats();
|
||||
showToast(`Recalculated costs for ${result.updated} archives`);
|
||||
showToast(t('stats.recalculatedCosts', { count: result.updated }));
|
||||
} catch {
|
||||
showToast('Failed to recalculate costs', 'error');
|
||||
showToast(t('stats.recalculateFailed'), 'error');
|
||||
} finally {
|
||||
setIsRecalculating(false);
|
||||
}
|
||||
@@ -596,7 +610,7 @@ export function StatsPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
|
||||
<div className="text-center py-12 text-bambu-gray">{t('stats.loadingStats')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -607,50 +621,50 @@ export function StatsPage() {
|
||||
const widgets: DashboardWidget[] = [
|
||||
{
|
||||
id: 'quick-stats',
|
||||
title: 'Quick Stats',
|
||||
component: <QuickStatsWidget stats={stats} currency={currency} />,
|
||||
title: t('stats.quickStats'),
|
||||
component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
|
||||
defaultSize: 2,
|
||||
},
|
||||
{
|
||||
id: 'success-rate',
|
||||
title: 'Success Rate',
|
||||
component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
|
||||
title: t('stats.successRate'),
|
||||
component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'time-accuracy',
|
||||
title: 'Time Accuracy',
|
||||
component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
|
||||
title: t('stats.timeAccuracy'),
|
||||
component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'filament-types',
|
||||
title: 'Filament Types',
|
||||
component: (size) => <FilamentTypesWidget stats={stats} size={size} />,
|
||||
title: t('stats.filamentTypes'),
|
||||
component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'failure-analysis',
|
||||
title: 'Failure Analysis (30 days)',
|
||||
component: (size) => <FailureAnalysisWidget size={size} />,
|
||||
title: t('stats.failureAnalysis'),
|
||||
component: (size) => <FailureAnalysisWidget size={size} t={t} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'print-activity',
|
||||
title: 'Print Activity',
|
||||
title: t('stats.printActivity'),
|
||||
component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
|
||||
defaultSize: 2,
|
||||
},
|
||||
{
|
||||
id: 'prints-by-printer',
|
||||
title: 'Prints by Printer',
|
||||
component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
|
||||
title: t('stats.printsByPrinter'),
|
||||
component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
|
||||
defaultSize: 2,
|
||||
},
|
||||
{
|
||||
id: 'filament-trends',
|
||||
title: 'Filament Usage Trends',
|
||||
component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
|
||||
title: t('stats.filamentTrends'),
|
||||
component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
|
||||
defaultSize: 4,
|
||||
},
|
||||
];
|
||||
@@ -660,8 +674,8 @@ export function StatsPage() {
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
|
||||
<h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
|
||||
<p className="text-bambu-gray">{t('stats.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Hidden widgets button - toggles panel in Dashboard */}
|
||||
@@ -674,7 +688,7 @@ export function StatsPage() {
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{hiddenCount} Hidden
|
||||
{t('stats.hiddenCount', { count: hiddenCount })}
|
||||
</Button>
|
||||
)}
|
||||
{/* Reset Layout */}
|
||||
@@ -683,27 +697,27 @@ export function StatsPage() {
|
||||
onClick={() => {
|
||||
localStorage.removeItem('bambusy-dashboard-layout');
|
||||
setDashboardKey(prev => prev + 1);
|
||||
showToast('Layout reset');
|
||||
showToast(t('stats.layoutReset'));
|
||||
}}
|
||||
disabled={!hasPermission('settings:update')}
|
||||
title={!hasPermission('settings:update') ? 'You do not have permission to reset layout' : undefined}
|
||||
title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset Layout
|
||||
{t('stats.resetLayout')}
|
||||
</Button>
|
||||
{/* Recalculate Costs */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleRecalculateCosts}
|
||||
disabled={isRecalculating || !hasPermission('archives:update_all')}
|
||||
title={!hasPermission('archives:update_all') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
|
||||
title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}
|
||||
>
|
||||
{isRecalculating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Calculator className="w-4 h-4" />
|
||||
)}
|
||||
Recalculate Costs
|
||||
{t('stats.recalculateCosts')}
|
||||
</Button>
|
||||
{/* Export dropdown */}
|
||||
<div className="relative">
|
||||
@@ -717,7 +731,7 @@ export function StatsPage() {
|
||||
) : (
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
)}
|
||||
Export Stats
|
||||
{t('stats.exportStats')}
|
||||
</Button>
|
||||
{showExportMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
|
||||
@@ -726,14 +740,14 @@ export function StatsPage() {
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Export as CSV
|
||||
{t('stats.exportAsCsv')}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
|
||||
onClick={() => handleExport('xlsx')}
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
Export as Excel
|
||||
{t('stats.exportAsExcel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers, Clock, Timer, Printer } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { PrinterStatus } from '../api/client';
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
type OverlaySize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface OverlayConfig {
|
||||
@@ -49,7 +52,7 @@ function formatTime(seconds: number): string {
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatETA(remainingMinutes: number): string {
|
||||
function formatETA(remainingMinutes: number, t: TFunction): string {
|
||||
const now = new Date();
|
||||
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
|
||||
const today = new Date();
|
||||
@@ -62,22 +65,22 @@ function formatETA(remainingMinutes: number): string {
|
||||
if (etaDay.getTime() === today.getTime()) {
|
||||
return timeStr;
|
||||
} else if (etaDay.getTime() === today.getTime() + 86400000) {
|
||||
return `Tomorrow ${timeStr}`;
|
||||
return `${t('streamOverlay.tomorrow')} ${timeStr}`;
|
||||
} else {
|
||||
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status: PrinterStatus): string {
|
||||
function getStatusText(status: PrinterStatus, t: TFunction): string {
|
||||
if (status.stg_cur_name) return status.stg_cur_name;
|
||||
|
||||
switch (status.state) {
|
||||
case 'RUNNING': return 'Printing';
|
||||
case 'PAUSE': return 'Paused';
|
||||
case 'FINISH': return 'Finished';
|
||||
case 'FAILED': return 'Failed';
|
||||
case 'IDLE': return 'Idle';
|
||||
default: return status.state || 'Unknown';
|
||||
case 'RUNNING': return t('streamOverlay.status.printing');
|
||||
case 'PAUSE': return t('streamOverlay.status.paused');
|
||||
case 'FINISH': return t('streamOverlay.status.finished');
|
||||
case 'FAILED': return t('streamOverlay.status.failed');
|
||||
case 'IDLE': return t('streamOverlay.status.idle');
|
||||
default: return status.state || t('streamOverlay.status.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +123,7 @@ function getSizeClasses(size: OverlaySize) {
|
||||
export function StreamOverlayPage() {
|
||||
const { printerId } = useParams<{ printerId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const id = parseInt(printerId || '0', 10);
|
||||
const [imageKey, setImageKey] = useState(Date.now());
|
||||
@@ -172,11 +176,11 @@ export function StreamOverlayPage() {
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
|
||||
document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
|
||||
return () => {
|
||||
document.title = 'Bambuddy';
|
||||
};
|
||||
}, [printer]);
|
||||
}, [printer, t]);
|
||||
|
||||
// Refresh stream on error
|
||||
const handleStreamError = () => {
|
||||
@@ -188,7 +192,7 @@ export function StreamOverlayPage() {
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-white">Invalid printer ID</p>
|
||||
<p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -196,7 +200,7 @@ export function StreamOverlayPage() {
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
<p className="text-gray-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -212,7 +216,7 @@ export function StreamOverlayPage() {
|
||||
<img
|
||||
key={imageKey}
|
||||
src={streamUrl}
|
||||
alt="Camera stream"
|
||||
alt={t('streamOverlay.cameraStream')}
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
onError={handleStreamError}
|
||||
/>
|
||||
@@ -253,7 +257,7 @@ export function StreamOverlayPage() {
|
||||
{/* Status text */}
|
||||
{config.showStatus && (
|
||||
<div className={`${sizes.text} text-white/70 mb-2`}>
|
||||
{getStatusText(status)}
|
||||
{getStatusText(status, t)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -261,7 +265,7 @@ export function StreamOverlayPage() {
|
||||
{config.showProgress && isPrinting && (
|
||||
<div className="mb-3">
|
||||
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
|
||||
<span className="text-white/70">Progress</span>
|
||||
<span className="text-white/70">{t('streamOverlay.progress')}</span>
|
||||
<span className="text-white font-bold">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
|
||||
@@ -301,7 +305,7 @@ export function StreamOverlayPage() {
|
||||
<div className={`flex items-center ${sizes.gap} text-white/70`}>
|
||||
<Clock className={sizes.icon} />
|
||||
<span className={`${sizes.text} text-white`}>
|
||||
ETA {formatETA(status.remaining_time)}
|
||||
{t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -312,7 +316,7 @@ export function StreamOverlayPage() {
|
||||
{/* Idle state */}
|
||||
{!isPrinting && (
|
||||
<div className={`${sizes.text} text-white/70 py-2`}>
|
||||
{status.connected ? 'Printer is idle' : 'Printer offline'}
|
||||
{status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
|
||||
@@ -17,6 +18,7 @@ interface FormData extends UserCreate {
|
||||
|
||||
export function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { user: currentUser, hasPermission } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -70,7 +72,7 @@ export function UsersPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast('User created successfully');
|
||||
showToast(t('users.toast.created'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -85,7 +87,7 @@ export function UsersPage() {
|
||||
setShowEditModal(false);
|
||||
setEditingUserId(null);
|
||||
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
showToast('User updated successfully');
|
||||
showToast(t('users.toast.updated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -96,7 +98,7 @@ export function UsersPage() {
|
||||
mutationFn: (id: number) => api.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('User deleted successfully');
|
||||
showToast(t('users.toast.deleted'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message, 'error');
|
||||
@@ -105,15 +107,15 @@ export function UsersPage() {
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!formData.username || !formData.password) {
|
||||
showToast('Please fill in all required fields', 'error');
|
||||
showToast(t('users.toast.fillRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
showToast('Password must be at least 6 characters', 'error');
|
||||
showToast(t('users.toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
@@ -128,11 +130,11 @@ export function UsersPage() {
|
||||
// Validate password confirmation if a new password is being set
|
||||
if (formData.password) {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
showToast('Passwords do not match', 'error');
|
||||
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
showToast('Password must be at least 6 characters', 'error');
|
||||
showToast(t('users.toast.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -187,7 +189,7 @@ export function UsersPage() {
|
||||
<CardContent className="py-6">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<Shield className="w-5 h-5" />
|
||||
<p className="text-white">You do not have permission to access this page.</p>
|
||||
<p className="text-white">{t('users.noPermission')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -202,17 +204,17 @@ export function UsersPage() {
|
||||
<button
|
||||
onClick={() => navigate('/settings?tab=users')}
|
||||
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title="Back to Settings"
|
||||
title={t('users.backToSettings')}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<UsersIcon className="w-6 h-6 text-bambu-green" />
|
||||
User Management
|
||||
{t('users.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-bambu-gray mt-1">
|
||||
Manage users and their access to your Bambuddy instance
|
||||
{t('users.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +225,7 @@ export function UsersPage() {
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create User
|
||||
{t('users.createUser')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -238,16 +240,16 @@ export function UsersPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Username
|
||||
{t('users.table.username')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Groups
|
||||
{t('users.table.groups')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Status
|
||||
{t('users.table.status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
|
||||
Actions
|
||||
{t('users.table.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -261,7 +263,7 @@ export function UsersPage() {
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.is_admin && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
|
||||
Admin
|
||||
{t('users.admin')}
|
||||
</span>
|
||||
)}
|
||||
{user.groups?.map(group => (
|
||||
@@ -281,7 +283,7 @@ export function UsersPage() {
|
||||
</span>
|
||||
))}
|
||||
{(!user.groups || user.groups.length === 0) && !user.is_admin && (
|
||||
<span className="text-bambu-gray">No groups</span>
|
||||
<span className="text-bambu-gray">{t('users.noGroups')}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -291,7 +293,7 @@ export function UsersPage() {
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
{user.is_active ? t('users.active') : t('users.inactive')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@@ -302,7 +304,7 @@ export function UsersPage() {
|
||||
onClick={() => startEdit(user)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
{t('users.edit')}
|
||||
</Button>
|
||||
{user.id !== currentUser?.id && (
|
||||
<Button
|
||||
@@ -311,7 +313,7 @@ export function UsersPage() {
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
{t('users.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,7 +343,7 @@ export function UsersPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Create User</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -359,34 +361,34 @@ export function UsersPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Username
|
||||
{t('users.form.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter username"
|
||||
placeholder={t('users.form.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Password
|
||||
{t('users.form.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter password"
|
||||
placeholder={t('users.form.passwordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Confirm Password
|
||||
{t('users.form.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -397,17 +399,17 @@ export function UsersPage() {
|
||||
? 'border-red-500'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
placeholder="Confirm password"
|
||||
placeholder={t('users.form.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
|
||||
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Groups
|
||||
{t('users.form.groups')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
{groups.map(group => (
|
||||
@@ -423,12 +425,12 @@ export function UsersPage() {
|
||||
/>
|
||||
<span className="text-sm text-white">{group.name}</span>
|
||||
{group.is_system && (
|
||||
<span className="text-xs text-yellow-400">(System)</span>
|
||||
<span className="text-xs text-yellow-400">({t('users.system')})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray">No groups available</p>
|
||||
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,7 +443,7 @@ export function UsersPage() {
|
||||
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('users.modal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
@@ -450,12 +452,12 @@ export function UsersPage() {
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
{t('users.modal.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create User
|
||||
{t('users.modal.createUser')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -479,7 +481,7 @@ export function UsersPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Edit2 className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Edit User</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -494,27 +496,27 @@ export function UsersPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Username
|
||||
{t('users.form.username')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter username"
|
||||
placeholder={t('users.form.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
|
||||
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder="Enter new password"
|
||||
placeholder={t('users.form.newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
@@ -522,7 +524,7 @@ export function UsersPage() {
|
||||
{formData.password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Confirm Password
|
||||
{t('users.form.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -533,18 +535,18 @@ export function UsersPage() {
|
||||
? 'border-red-500'
|
||||
: 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
placeholder="Confirm new password"
|
||||
placeholder={t('users.form.confirmNewPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
minLength={6}
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
|
||||
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Groups
|
||||
{t('users.form.groups')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
|
||||
{groups.map(group => (
|
||||
@@ -560,12 +562,12 @@ export function UsersPage() {
|
||||
/>
|
||||
<span className="text-sm text-white">{group.name}</span>
|
||||
{group.is_system && (
|
||||
<span className="text-xs text-yellow-400">(System)</span>
|
||||
<span className="text-xs text-yellow-400">({t('users.system')})</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray">No groups available</p>
|
||||
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,7 +577,7 @@ export function UsersPage() {
|
||||
variant="secondary"
|
||||
onClick={closeEditModal}
|
||||
>
|
||||
Cancel
|
||||
{t('users.modal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleUpdate(editingUserId)}
|
||||
@@ -584,12 +586,12 @@ export function UsersPage() {
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
{t('users.modal.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Changes
|
||||
{t('users.modal.saveChanges')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -602,9 +604,9 @@ export function UsersPage() {
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteUserId !== null && (
|
||||
<ConfirmModal
|
||||
title="Delete User"
|
||||
message={`Are you sure you want to delete this user? This action cannot be undone.`}
|
||||
confirmText="Delete User"
|
||||
title={t('users.deleteModal.title')}
|
||||
message={t('users.deleteModal.message')}
|
||||
confirmText={t('users.deleteModal.confirm')}
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate(deleteUserId);
|
||||
|
||||
Reference in New Issue
Block a user