1135 lines
44 KiB
JavaScript
1135 lines
44 KiB
JavaScript
import { PureComponent } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Button from '@mui/material/Button';
|
|
import Chip from '@mui/material/Chip';
|
|
import Divider from '@mui/material/Divider';
|
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
import IconButton from '@mui/material/IconButton';
|
|
import Switch from '@mui/material/Switch';
|
|
import TextField from '@mui/material/TextField';
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import Typography from '@mui/material/Typography';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
|
import CheckIcon from '@mui/icons-material/Check';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
|
import LocalPrintshopOutlinedIcon from '@mui/icons-material/LocalPrintshopOutlined';
|
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
|
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
|
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
import QRCode from 'qrcode';
|
|
import BackendError from '../BackendError';
|
|
import companyAutomat from '../../automats/companyAutomat';
|
|
import { backendErrorFrom } from '../../utils/backendError';
|
|
import { notifyError } from '../../utils/notify';
|
|
|
|
function formatDateTime(val) {
|
|
if (!val) return null;
|
|
try {
|
|
const d = new Date(val);
|
|
if (isNaN(d.getTime())) return null;
|
|
return d.toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class AgentQrCode extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = { dataUrl: null };
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.generateQr();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.value !== this.props.value || prevProps.size !== this.props.size) {
|
|
this.generateQr();
|
|
}
|
|
}
|
|
|
|
async generateQr() {
|
|
const { value, size = 110 } = this.props;
|
|
if (!value) {
|
|
this.setState({ dataUrl: null });
|
|
return;
|
|
}
|
|
try {
|
|
const dataUrl = await QRCode.toDataURL(value, {
|
|
width: size,
|
|
margin: 1,
|
|
color: {
|
|
dark: '#000000',
|
|
light: '#ffffff',
|
|
},
|
|
errorCorrectionLevel: 'M',
|
|
});
|
|
this.setState({ dataUrl });
|
|
} catch (err) {
|
|
console.error('Failed to generate QR code:', err);
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const { size = 110 } = this.props;
|
|
const { dataUrl } = this.state;
|
|
if (!dataUrl) {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
width: size,
|
|
height: size,
|
|
bgcolor: 'action.hover',
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Box
|
|
component="img"
|
|
src={dataUrl}
|
|
alt="Pairing QR Code"
|
|
sx={{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: 1,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: '#fff',
|
|
p: 0.5,
|
|
display: 'block',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderPrinterDiagnostics(printer, live) {
|
|
const isOnline = printer?.online ?? (live ? !live.unreachable && live.status !== 'offline' : false);
|
|
const isOpen = printer?.open ?? (live?.errors?.headOpen || live?.headOpen || false);
|
|
const isPaused = printer?.pause ?? (live?.errors?.pause || live?.pause || false);
|
|
const isError = printer?.error ?? (live?.errors?.other || live?.error || false);
|
|
const isUnreachable = !isOnline || (live?.errors?.unreachable || false);
|
|
const labelLength = printer?.labelLength ?? live?.labelLength;
|
|
|
|
if (!isOnline) {
|
|
return (
|
|
<Chip
|
|
label="Offline"
|
|
size="small"
|
|
variant="outlined"
|
|
color="default"
|
|
sx={{ fontSize: '0.7rem', height: 20 }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const errorChips = [];
|
|
if (isOpen) {
|
|
errorChips.push(
|
|
<Chip
|
|
key="headOpen"
|
|
label="Druckkopf offen"
|
|
size="small"
|
|
color="error"
|
|
sx={{ fontSize: '0.7rem', height: 20, fontWeight: 600 }}
|
|
/>,
|
|
);
|
|
}
|
|
if (isPaused) {
|
|
errorChips.push(
|
|
<Chip
|
|
key="pause"
|
|
label="Pausiert"
|
|
size="small"
|
|
color="warning"
|
|
sx={{ fontSize: '0.7rem', height: 20, fontWeight: 600 }}
|
|
/>,
|
|
);
|
|
}
|
|
if (isUnreachable) {
|
|
errorChips.push(
|
|
<Chip
|
|
key="unreachable"
|
|
label="Nicht erreichbar"
|
|
size="small"
|
|
color="error"
|
|
sx={{ fontSize: '0.7rem', height: 20, fontWeight: 600 }}
|
|
/>,
|
|
);
|
|
}
|
|
if (isError) {
|
|
errorChips.push(
|
|
<Chip
|
|
key="other"
|
|
label="Fehler"
|
|
size="small"
|
|
color="error"
|
|
sx={{ fontSize: '0.7rem', height: 20, fontWeight: 600 }}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (errorChips.length > 0) {
|
|
return <Box sx={{ display: 'inline-flex', gap: 0.5, flexWrap: 'wrap' }}>{errorChips}</Box>;
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'inline-flex', gap: 0.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<Chip
|
|
label="Bereit"
|
|
size="small"
|
|
color="success"
|
|
variant="outlined"
|
|
sx={{ fontSize: '0.7rem', height: 20, fontWeight: 600 }}
|
|
/>
|
|
{labelLength != null ? (
|
|
<Chip
|
|
label={`Etikett: ${labelLength} px`}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{ fontSize: '0.7rem', height: 20 }}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default class PrinterAgentSettingsPanel extends PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
printeragents: Array.isArray(companyAutomat.state.printeragents)
|
|
? companyAutomat.state.printeragents
|
|
: [],
|
|
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
|
|
? companyAutomat.state.agentprinters
|
|
: [],
|
|
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
|
? companyAutomat.state.livePrinters
|
|
: [],
|
|
error: companyAutomat.state.printeragentsError
|
|
? backendErrorFrom(companyAutomat.state.printeragentsError)
|
|
: null,
|
|
newAgentName: '',
|
|
creatingAgent: false,
|
|
deletingId: null,
|
|
patchingAgentId: null,
|
|
editingId: null,
|
|
editNameDraft: '',
|
|
sortDrafts: {},
|
|
testingPrinterId: null,
|
|
};
|
|
this.handleRetry = this.handleRetry.bind(this);
|
|
this.handleCreateAgent = this.handleCreateAgent.bind(this);
|
|
this.handleDeleteAgent = this.handleDeleteAgent.bind(this);
|
|
this.handleToggleAgentEnabled = this.handleToggleAgentEnabled.bind(this);
|
|
this.handleStartRename = this.handleStartRename.bind(this);
|
|
this.handleCancelRename = this.handleCancelRename.bind(this);
|
|
this.handleSaveRename = this.handleSaveRename.bind(this);
|
|
this.handleSortDraftChange = this.handleSortDraftChange.bind(this);
|
|
this.handleSortSubmit = this.handleSortSubmit.bind(this);
|
|
this.handleMoveAgent = this.handleMoveAgent.bind(this);
|
|
this.handleTogglePrinterEnabled = this.handleTogglePrinterEnabled.bind(this);
|
|
this.handleMovePrinter = this.handleMovePrinter.bind(this);
|
|
this.handleDeletePrinter = this.handleDeletePrinter.bind(this);
|
|
this.handleTestPrint = this.handleTestPrint.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
|
printeragents: Array.isArray(state.printeragents) ? state.printeragents : [],
|
|
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
|
|
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
|
error: state.printeragentsError ? backendErrorFrom(state.printeragentsError) : null,
|
|
}));
|
|
if (companyAutomat.state.printeragentsStatus === 'idle') {
|
|
void companyAutomat.actions.loadPrinterAgents().catch(() => {});
|
|
}
|
|
if (companyAutomat.state.agentprintersStatus === 'idle') {
|
|
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
|
|
}
|
|
void companyAutomat.actions.loadLivePrinters().catch(() => {});
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.unsub?.();
|
|
}
|
|
|
|
handleRetry() {
|
|
void companyAutomat.actions.loadPrinterAgents().catch(notifyError);
|
|
void companyAutomat.actions.loadAgentPrinters().catch(notifyError);
|
|
void companyAutomat.actions.loadLivePrinters().catch(() => {});
|
|
}
|
|
|
|
async handleCreateAgent() {
|
|
const { newAgentName, printeragents } = this.state;
|
|
const trimmed = newAgentName.trim();
|
|
if (!trimmed) return;
|
|
|
|
this.setState({ creatingAgent: true });
|
|
try {
|
|
const nextSort =
|
|
printeragents.length > 0
|
|
? Math.max(...printeragents.map((a) => Number(a.sort) || 0)) + 1
|
|
: 0;
|
|
await companyAutomat.actions.createPrinterAgent({
|
|
name: trimmed,
|
|
sort: nextSort,
|
|
});
|
|
this.setState({ newAgentName: '' });
|
|
enqueueSnackbar('Drucker-Agent hinzugefügt.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ creatingAgent: false });
|
|
}
|
|
}
|
|
|
|
async handleDeleteAgent(agent) {
|
|
if (!window.confirm(`Möchten Sie den Drucker-Agenten „${agent.name}“ wirklich löschen?`)) {
|
|
return;
|
|
}
|
|
this.setState({ deletingId: agent.id });
|
|
try {
|
|
await companyAutomat.actions.deletePrinterAgent(agent.id);
|
|
enqueueSnackbar('Drucker-Agent gelöscht.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ deletingId: null });
|
|
}
|
|
}
|
|
|
|
async handleToggleAgentEnabled(agent, nextValue) {
|
|
this.setState({ patchingAgentId: agent.id });
|
|
try {
|
|
await companyAutomat.actions.updatePrinterAgent(agent.id, {
|
|
enabled: Boolean(nextValue),
|
|
});
|
|
enqueueSnackbar(
|
|
nextValue ? 'Drucker-Agent aktiviert.' : 'Drucker-Agent deaktiviert.',
|
|
{ variant: 'success' },
|
|
);
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingAgentId: null });
|
|
}
|
|
}
|
|
|
|
handleStartRename(agent) {
|
|
this.setState({ editingId: agent.id, editNameDraft: agent.name || '' });
|
|
}
|
|
|
|
handleCancelRename() {
|
|
this.setState({ editingId: null, editNameDraft: '' });
|
|
}
|
|
|
|
async handleSaveRename(agent) {
|
|
const { editNameDraft } = this.state;
|
|
const trimmed = editNameDraft.trim();
|
|
if (!trimmed || trimmed === agent.name) {
|
|
this.handleCancelRename();
|
|
return;
|
|
}
|
|
this.setState({ patchingAgentId: agent.id });
|
|
try {
|
|
await companyAutomat.actions.updatePrinterAgent(agent.id, { name: trimmed });
|
|
enqueueSnackbar('Name aktualisiert.', { variant: 'success' });
|
|
this.setState({ editingId: null, editNameDraft: '' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingAgentId: null });
|
|
}
|
|
}
|
|
|
|
handleSortDraftChange(agentId, value) {
|
|
this.setState((prev) => ({
|
|
sortDrafts: { ...prev.sortDrafts, [agentId]: value },
|
|
}));
|
|
}
|
|
|
|
async handleSortSubmit(agent) {
|
|
const draft = this.state.sortDrafts[agent.id];
|
|
if (draft === undefined) return;
|
|
const nextSort = Number(draft);
|
|
if (isNaN(nextSort) || nextSort === (agent.sort ?? 0)) {
|
|
this.setState((prev) => {
|
|
const next = { ...prev.sortDrafts };
|
|
delete next[agent.id];
|
|
return { sortDrafts: next };
|
|
});
|
|
return;
|
|
}
|
|
this.setState({ patchingAgentId: agent.id });
|
|
try {
|
|
await companyAutomat.actions.updatePrinterAgent(agent.id, { sort: nextSort });
|
|
this.setState((prev) => {
|
|
const next = { ...prev.sortDrafts };
|
|
delete next[agent.id];
|
|
return { sortDrafts: next };
|
|
});
|
|
enqueueSnackbar('Reihenfolge gespeichert.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingAgentId: null });
|
|
}
|
|
}
|
|
|
|
async handleMoveAgent(agent, direction) {
|
|
const { printeragents } = this.state;
|
|
const currentIndex = printeragents.findIndex((a) => a.id === agent.id);
|
|
if (currentIndex === -1) return;
|
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
|
if (targetIndex < 0 || targetIndex >= printeragents.length) return;
|
|
const targetAgent = printeragents[targetIndex];
|
|
|
|
const currentSort = Number(agent.sort) || 0;
|
|
const targetSort = Number(targetAgent.sort) || 0;
|
|
|
|
let newCurrentSort = targetSort;
|
|
let newTargetSort = currentSort;
|
|
if (newCurrentSort === newTargetSort) {
|
|
newCurrentSort = direction === 'up' ? targetSort - 1 : targetSort + 1;
|
|
}
|
|
|
|
this.setState({ patchingAgentId: agent.id });
|
|
try {
|
|
await Promise.all([
|
|
companyAutomat.actions.updatePrinterAgent(agent.id, { sort: newCurrentSort }),
|
|
companyAutomat.actions.updatePrinterAgent(targetAgent.id, { sort: newTargetSort }),
|
|
]);
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingAgentId: null });
|
|
}
|
|
}
|
|
|
|
async handleTogglePrinterEnabled(printer, nextValue) {
|
|
try {
|
|
await companyAutomat.actions.updateAgentPrinter(printer.id, {
|
|
enabled: Boolean(nextValue),
|
|
});
|
|
enqueueSnackbar(
|
|
nextValue ? 'Drucker aktiviert.' : 'Drucker deaktiviert.',
|
|
{ variant: 'success' },
|
|
);
|
|
} catch (err) {
|
|
notifyError(err);
|
|
}
|
|
}
|
|
|
|
async handleMovePrinter(printer, direction) {
|
|
const { agentprinters } = this.state;
|
|
const printersForAgent = agentprinters.filter(
|
|
(p) => p.printeragentId === printer.printeragentId,
|
|
);
|
|
const currentIndex = printersForAgent.findIndex((p) => p.id === printer.id);
|
|
if (currentIndex === -1) return;
|
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
|
if (targetIndex < 0 || targetIndex >= printersForAgent.length) return;
|
|
|
|
const reordered = [...printersForAgent];
|
|
const [moved] = reordered.splice(currentIndex, 1);
|
|
reordered.splice(targetIndex, 0, moved);
|
|
|
|
try {
|
|
await companyAutomat.actions.reorderAgentPrinters({
|
|
printeragentId: printer.printeragentId,
|
|
agentprinterIds: reordered.map((p) => p.id),
|
|
});
|
|
} catch (err) {
|
|
notifyError(err);
|
|
}
|
|
}
|
|
|
|
async handleDeletePrinter(printer) {
|
|
const label = printer.model || printer.serial || `#${printer.id}`;
|
|
if (!window.confirm(`Möchten Sie den Drucker „${label}“ wirklich entfernen?`)) {
|
|
return;
|
|
}
|
|
try {
|
|
await companyAutomat.actions.deleteAgentPrinter(printer.id);
|
|
enqueueSnackbar('Drucker entfernt.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
}
|
|
}
|
|
|
|
async handleTestPrint(printer) {
|
|
if (!printer?.id) return;
|
|
this.setState({ testingPrinterId: printer.id });
|
|
try {
|
|
await companyAutomat.actions.sendTestPrint(printer.id, {
|
|
model: printer.model || printer.type || 'Zebra',
|
|
});
|
|
enqueueSnackbar(
|
|
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Drucker-Warteschlange gesendet.`,
|
|
{ variant: 'success' },
|
|
);
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ testingPrinterId: null });
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
error,
|
|
printeragents,
|
|
agentprinters,
|
|
livePrinters,
|
|
newAgentName,
|
|
creatingAgent,
|
|
deletingId,
|
|
patchingAgentId,
|
|
editingId,
|
|
editNameDraft,
|
|
testingPrinterId,
|
|
} = this.state;
|
|
|
|
if (error && printeragents.length === 0) {
|
|
return (
|
|
<Box sx={{ p: 3 }}>
|
|
<BackendError error={error} onRetry={this.handleRetry} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Build lookup maps for fast access
|
|
const livePrintersBySerial = new Map();
|
|
for (const lp of livePrinters) {
|
|
if (lp.serial) livePrintersBySerial.set(lp.serial, lp);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ p: 3, maxWidth: 900, mx: 'auto' }}>
|
|
<Box sx={{ mb: 2 }}>
|
|
<Typography variant="h6" fontWeight={600} gutterBottom>
|
|
Drucker-Agenten
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Verwalten Sie externe Drucker-Agenten und die daran angeschlossenen Drucker. Nach
|
|
Erstellung eines Agenten geben Sie die 6-stellige PIN auf dem Gerät ein oder scannen
|
|
Sie den QR-Code, um die Kopplung abzuschließen.
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Add new agent row */}
|
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, alignItems: 'center' }}>
|
|
<TextField
|
|
size="small"
|
|
placeholder="Neuer Drucker-Agent Name (z.B. Bar, Küche)"
|
|
value={newAgentName}
|
|
onChange={(e) => this.setState({ newAgentName: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void this.handleCreateAgent();
|
|
}
|
|
}}
|
|
disabled={creatingAgent}
|
|
autoComplete="off"
|
|
slotProps={{
|
|
htmlInput: {
|
|
autoComplete: 'off',
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
spellCheck: false,
|
|
},
|
|
input: {
|
|
startAdornment: (
|
|
<LocalPrintshopOutlinedIcon sx={{ mr: 1, color: 'text.secondary' }} />
|
|
),
|
|
},
|
|
}}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
startIcon={<AddIcon />}
|
|
onClick={this.handleCreateAgent}
|
|
disabled={creatingAgent || !newAgentName.trim()}
|
|
>
|
|
Hinzufügen
|
|
</Button>
|
|
</Box>
|
|
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
{printeragents.length === 0 ? (
|
|
<Box sx={{ textAlign: 'center', py: 6, color: 'text.secondary' }}>
|
|
<LocalPrintshopOutlinedIcon sx={{ fontSize: 48, mb: 1, opacity: 0.5 }} />
|
|
<Typography variant="body2">Keine Drucker-Agenten angelegt.</Typography>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
{printeragents.map((agent, index) => {
|
|
const isDeleting = deletingId === agent.id;
|
|
const isPatching = patchingAgentId === agent.id;
|
|
const isEditing = editingId === agent.id;
|
|
const isPaired = agent.pairedAt != null;
|
|
const isEnabled = agent.enabled !== false;
|
|
const pairedDateText = formatDateTime(agent.pairedAt);
|
|
const lastseenText = formatDateTime(agent.lastseenAt) ?? 'Nie';
|
|
const updatedText = formatDateTime(agent.updatedAt) ?? '—';
|
|
|
|
const printersForAgent = agentprinters.filter(
|
|
(p) => p.printeragentId === agent.id,
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
key={agent.id}
|
|
sx={{
|
|
p: 2,
|
|
borderRadius: 2,
|
|
border: '1px solid',
|
|
borderColor: !isEnabled
|
|
? 'action.disabledBackground'
|
|
: isPaired
|
|
? 'divider'
|
|
: 'warning.light',
|
|
bgcolor: 'background.paper',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1.5,
|
|
opacity: isEnabled ? 1 : 0.72,
|
|
transition: 'opacity 0.2s ease, border-color 0.2s ease',
|
|
}}
|
|
>
|
|
{/* Header: Icon, Name (or Edit input), Status chips, Enabled switch, Delete */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 1,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1, minWidth: 260 }}>
|
|
<LocalPrintshopOutlinedIcon
|
|
sx={{
|
|
color: !isEnabled
|
|
? 'text.disabled'
|
|
: isPaired
|
|
? 'primary.main'
|
|
: 'warning.main',
|
|
fontSize: 24,
|
|
}}
|
|
/>
|
|
|
|
{isEditing ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
|
<TextField
|
|
size="small"
|
|
value={editNameDraft}
|
|
onChange={(e) => this.setState({ editNameDraft: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void this.handleSaveRename(agent);
|
|
} else if (e.key === 'Escape') {
|
|
this.handleCancelRename();
|
|
}
|
|
}}
|
|
autoFocus
|
|
disabled={isPatching}
|
|
autoComplete="off"
|
|
slotProps={{
|
|
htmlInput: {
|
|
autoComplete: 'off',
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
spellCheck: false,
|
|
},
|
|
}}
|
|
sx={{ minWidth: 200 }}
|
|
/>
|
|
<IconButton
|
|
size="small"
|
|
color="primary"
|
|
onClick={() => void this.handleSaveRename(agent)}
|
|
disabled={isPatching}
|
|
>
|
|
<CheckIcon fontSize="small" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleCancelRename}
|
|
disabled={isPatching}
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography
|
|
variant="subtitle1"
|
|
fontWeight={600}
|
|
sx={{ color: isEnabled ? 'text.primary' : 'text.disabled' }}
|
|
>
|
|
{agent.name}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.handleStartRename(agent)}
|
|
disabled={isPatching || isDeleting}
|
|
sx={{ p: 0.5, color: 'text.secondary' }}
|
|
>
|
|
<EditOutlinedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Pairing status badge */}
|
|
{isPaired ? (
|
|
<Chip
|
|
icon={<CheckCircleOutlinedIcon />}
|
|
label="Gekoppelt"
|
|
size="small"
|
|
variant="outlined"
|
|
color="success"
|
|
sx={{ fontWeight: 600, fontSize: '0.75rem' }}
|
|
/>
|
|
) : (
|
|
<Chip
|
|
icon={<HourglassEmptyIcon />}
|
|
label="Nicht gekoppelt"
|
|
size="small"
|
|
variant="outlined"
|
|
color="warning"
|
|
sx={{ fontWeight: 600, fontSize: '0.75rem' }}
|
|
/>
|
|
)}
|
|
|
|
{/* Agent version */}
|
|
{agent.agentversion ? (
|
|
<Tooltip title="Agent Software-Version">
|
|
<Chip
|
|
label={`v${agent.agentversion}`}
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '0.75rem' }}
|
|
/>
|
|
</Tooltip>
|
|
) : null}
|
|
</Box>
|
|
|
|
{/* Enabled Switch and Delete action */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={isEnabled}
|
|
disabled={isPatching || isDeleting}
|
|
onChange={(e) => void this.handleToggleAgentEnabled(agent, e.target.checked)}
|
|
color="success"
|
|
/>
|
|
}
|
|
label={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontSize: '0.8125rem',
|
|
fontWeight: 500,
|
|
color: isEnabled ? 'text.primary' : 'text.disabled',
|
|
}}
|
|
>
|
|
{isEnabled ? 'Aktiv' : 'Inaktiv'}
|
|
</Typography>
|
|
}
|
|
sx={{ m: 0 }}
|
|
/>
|
|
|
|
<IconButton
|
|
size="small"
|
|
aria-label={`Drucker-Agent ${agent.name} löschen`}
|
|
onClick={() => void this.handleDeleteAgent(agent)}
|
|
disabled={isDeleting || creatingAgent || isPatching}
|
|
color="error"
|
|
>
|
|
<DeleteOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Body: Pairing details with QR code or Discovered Printers */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1.5,
|
|
pt: 0.5,
|
|
}}
|
|
>
|
|
{!isPaired ? (
|
|
(() => {
|
|
const domain = typeof window !== 'undefined' ? window.location.host : '';
|
|
const qrPayload = JSON.stringify({
|
|
domain,
|
|
pin: String(agent.pairingpin || ''),
|
|
});
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 2,
|
|
flexWrap: 'wrap',
|
|
bgcolor: 'action.hover',
|
|
p: 1.5,
|
|
borderRadius: 1.5,
|
|
border: '1px solid',
|
|
borderColor: 'warning.light',
|
|
}}
|
|
>
|
|
<Tooltip title="QR-Code zum Koppeln mit dem Drucker-Agenten">
|
|
<Box sx={{ display: 'inline-flex' }}>
|
|
<AgentQrCode value={qrPayload} size={110} />
|
|
</Box>
|
|
</Tooltip>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minWidth: 220 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
|
Kopplungs-PIN:
|
|
</Typography>
|
|
<Tooltip title="PIN in die Zwischenablage kopieren">
|
|
<Chip
|
|
icon={<ContentCopyIcon sx={{ fontSize: 14 }} />}
|
|
label={agent.pairingpin || '—'}
|
|
onClick={() => {
|
|
if (agent.pairingpin) {
|
|
navigator.clipboard?.writeText(agent.pairingpin);
|
|
enqueueSnackbar('PIN in Zwischenablage kopiert.', { variant: 'info' });
|
|
}
|
|
}}
|
|
clickable
|
|
color="warning"
|
|
variant="filled"
|
|
sx={{
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
fontSize: '0.9rem',
|
|
letterSpacing: '0.1em',
|
|
px: 0.5,
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
|
Domain:
|
|
</Typography>
|
|
<Chip
|
|
label={domain}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
|
|
/>
|
|
<Tooltip title="QR-Daten (JSON) kopieren">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => {
|
|
navigator.clipboard?.writeText(qrPayload);
|
|
enqueueSnackbar('QR-Daten kopiert.', { variant: 'info' });
|
|
}}
|
|
sx={{ p: 0.5 }}
|
|
>
|
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
|
Scannen Sie den QR-Code mit dem Drucker-Agenten oder geben Sie Domain und PIN manuell ein.
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})()
|
|
) : (
|
|
/* Discovered printers section */
|
|
<Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'action.hover' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography variant="subtitle2" fontWeight={600} color="text.secondary">
|
|
Erkannte Drucker ({printersForAgent.length})
|
|
</Typography>
|
|
</Box>
|
|
{printersForAgent.length === 0 ? (
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{ fontStyle: 'italic', py: 0.5 }}
|
|
>
|
|
Keine Drucker erkannt. Schließen Sie einen Drucker per USB an den Agenten an.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{printersForAgent.map((p, pIdx) => {
|
|
const pEnabled = p.enabled !== false;
|
|
const live =
|
|
(p.serial ? livePrintersBySerial.get(p.serial) : null) ??
|
|
livePrinters.find(
|
|
(lp) => lp.agentId === agent.id && lp.serial === p.serial,
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
key={p.id}
|
|
sx={{
|
|
p: 1.25,
|
|
borderRadius: 1,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
bgcolor: 'background.paper',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 1.5,
|
|
flexWrap: 'wrap',
|
|
opacity: pEnabled ? 1 : 0.65,
|
|
transition: 'opacity 0.2s ease',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
<PrintOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{p.model || 'Drucker'}
|
|
</Typography>
|
|
{p.type ? (
|
|
<Chip
|
|
label={p.type.toUpperCase()}
|
|
size="small"
|
|
color="primary"
|
|
variant="outlined"
|
|
sx={{ fontWeight: 600, fontSize: '0.7rem', height: 20 }}
|
|
/>
|
|
) : null}
|
|
{p.serial ? (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ fontFamily: 'monospace' }}
|
|
>
|
|
({p.serial})
|
|
</Typography>
|
|
) : null}
|
|
{renderPrinterDiagnostics(p, live)}
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{/* Test print button */}
|
|
<Tooltip title="Test-Etikett an Drucker senden (ZPL)">
|
|
<span>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon={<PrintOutlinedIcon sx={{ fontSize: 15 }} />}
|
|
disabled={testingPrinterId === p.id}
|
|
onClick={() => void this.handleTestPrint(p)}
|
|
sx={{
|
|
textTransform: 'none',
|
|
fontSize: '0.75rem',
|
|
py: 0.25,
|
|
px: 1,
|
|
minHeight: 26,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{testingPrinterId === p.id ? 'Druckt…' : 'Testdruck'}
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
|
|
{/* Enabled toggle switch */}
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={pEnabled}
|
|
onChange={(e) =>
|
|
void this.handleTogglePrinterEnabled(p, e.target.checked)
|
|
}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography
|
|
variant="caption"
|
|
color={pEnabled ? 'text.primary' : 'text.disabled'}
|
|
>
|
|
{pEnabled ? 'Aktiv' : 'Inaktiv'}
|
|
</Typography>
|
|
}
|
|
sx={{ m: 0 }}
|
|
/>
|
|
|
|
{/* Reorder buttons */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<Tooltip title="Nach oben verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={pIdx === 0}
|
|
onClick={() => void this.handleMovePrinter(p, 'up')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip title="Nach unten verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={pIdx === printersForAgent.length - 1}
|
|
onClick={() => void this.handleMovePrinter(p, 'down')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Delete printer button */}
|
|
<Tooltip title="Drucker entfernen">
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
onClick={() => void this.handleDeletePrinter(p)}
|
|
sx={{ p: 0.5 }}
|
|
>
|
|
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Agent sort order controls */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
gap: 0.5,
|
|
pt: 0.5,
|
|
}}
|
|
>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Reihenfolge:
|
|
</Typography>
|
|
<Tooltip title="Nach oben verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={index === 0 || isPatching || isDeleting}
|
|
onClick={() => void this.handleMoveAgent(agent, 'up')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip title="Nach unten verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={index === printeragents.length - 1 || isPatching || isDeleting}
|
|
onClick={() => void this.handleMoveAgent(agent, 'down')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<TextField
|
|
type="number"
|
|
size="small"
|
|
disabled={isPatching || isDeleting}
|
|
value={this.state.sortDrafts[agent.id] ?? agent.sort ?? 0}
|
|
onChange={(e) => this.handleSortDraftChange(agent.id, e.target.value)}
|
|
onBlur={() => void this.handleSortSubmit(agent)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.target.blur();
|
|
}
|
|
}}
|
|
slotProps={{
|
|
htmlInput: {
|
|
step: 1,
|
|
style: { width: 44, padding: '3px 6px', textAlign: 'center', fontSize: '0.8125rem' },
|
|
},
|
|
}}
|
|
sx={{ width: 56 }}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Dates Row */}
|
|
<Divider sx={{ my: 0.25 }} />
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 2.5,
|
|
color: 'text.secondary',
|
|
}}
|
|
>
|
|
{isPaired && pairedDateText ? (
|
|
<Typography variant="caption">
|
|
Gekoppelt am: <strong>{pairedDateText}</strong>
|
|
</Typography>
|
|
) : null}
|
|
<Typography variant="caption">
|
|
Zuletzt gesehen: <strong>{lastseenText}</strong>
|
|
</Typography>
|
|
<Typography variant="caption">
|
|
Aktualisiert: <strong>{updatedText}</strong>
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
}
|