feat(frontend): Task 15 - all React pages and components

Implements the full SPA UI:
- App.tsx with BrowserRouter, ProtectedRoute, and all page routes
- Sidebar with NavLink active state and logout
- Login page with JWT auth flow
- Dashboard with parallel data fetching and status tiles
- MqttBrokers: CRUD with test-connection button and Topics link
- MqttTopics: discovery, filter, json_path inline edit, ignore
- PgSources: CRUD form with all required fields
- Datapoints: table with inline label/unit editing via onBlur
- Displays: layout dropdown, refresh interval, last-seen timestamp
- Layouts: left panel list + right panel element editor
- EpaperPreview: scaled e-paper simulation with live data support
- StatusBadge: color-coded status chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-06 13:06:40 +02:00
parent 49089d6d17
commit a50ef96eb7
12 changed files with 1388 additions and 0 deletions

View File

@ -0,0 +1,41 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { isLoggedIn } from './api/client';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import MqttBrokers from './pages/MqttBrokers';
import MqttTopics from './pages/MqttTopics';
import PgSources from './pages/PgSources';
import Datapoints from './pages/Datapoints';
import Displays from './pages/Displays';
import Layouts from './pages/Layouts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (!isLoggedIn()) return <Navigate to="/login" replace />;
return (
<div className="flex min-h-screen bg-gray-100">
<Sidebar />
<main className="ml-56 flex-1 p-6 overflow-auto">
{children}
</main>
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/mqtt/brokers" element={<ProtectedRoute><MqttBrokers /></ProtectedRoute>} />
<Route path="/mqtt/topics/:brokerId" element={<ProtectedRoute><MqttTopics /></ProtectedRoute>} />
<Route path="/pg/sources" element={<ProtectedRoute><PgSources /></ProtectedRoute>} />
<Route path="/datapoints" element={<ProtectedRoute><Datapoints /></ProtectedRoute>} />
<Route path="/displays" element={<ProtectedRoute><Displays /></ProtectedRoute>} />
<Route path="/layouts" element={<ProtectedRoute><Layouts /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
interface LayoutElement {
id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
style: string;
static_text: string | null;
datapoint_id: number | null;
}
interface DisplayJson {
display_id: number;
layout: {
id: number;
width: number;
height: number;
};
elements: Array<{
type: string;
x: number;
y: number;
width?: number;
height?: number;
font_size: number;
style: string;
static_text?: string;
label?: string;
value?: string;
unit?: string;
}>;
}
interface Display {
id: number;
name: string;
layout_id: number | null;
}
interface EpaperPreviewProps {
layoutId: number;
elements: LayoutElement[];
layoutWidth?: number;
layoutHeight?: number;
}
const SCALE = 0.5;
export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, layoutHeight = 480 }: EpaperPreviewProps) {
const [displayJson, setDisplayJson] = useState<DisplayJson | null>(null);
useEffect(() => {
// Find a display using this layout and fetch its JSON
api.get<Display[]>('/api/displays')
.then((displays) => {
const d = displays.find((disp) => disp.layout_id === layoutId);
if (!d) return;
return api.get<DisplayJson>(`/api/display/${d.id}`).then(setDisplayJson).catch(() => {});
})
.catch(() => {});
}, [layoutId]);
const w = layoutWidth * SCALE;
const h = layoutHeight * SCALE;
const renderEl = (el: typeof elements[0], _idx?: number) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return (
<div
key={el.id}
style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }}
/>
);
}
if (el.type === 'rect') {
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
width: (el.width ?? 80) * SCALE,
height: (el.height ?? 40) * SCALE,
border: '1px solid #000',
}}
/>
);
}
// label or value elements
const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`);
const isCard = el.style === 'card';
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
fontSize: fs,
fontFamily: 'monospace',
color: '#000',
background: isCard ? '#e8e8e8' : 'transparent',
border: isCard ? '1px solid #aaa' : 'none',
padding: isCard ? '2px 4px' : 0,
whiteSpace: 'nowrap',
lineHeight: 1.2,
}}
>
{text}
</div>
);
};
// Use live display JSON if available, else render from elements prop
const liveEls = displayJson?.elements;
return (
<div className="mt-4">
<p className="text-xs text-gray-500 mb-2">
E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'}
</p>
<div
style={{
position: 'relative',
width: w,
height: h,
backgroundColor: '#f5f0e8',
border: '2px solid #1a1a1a',
borderRadius: 4,
overflow: 'hidden',
boxShadow: '2px 2px 8px rgba(0,0,0,0.2)',
}}
>
{liveEls
? liveEls.map((el, idx) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }} />;
}
if (el.type === 'rect') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 80) * SCALE, height: (el.height ?? 40) * SCALE, border: '1px solid #000' }} />;
}
const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? ''));
const isCard = el.style === 'card';
return (
<div key={idx} style={{ position: 'absolute', left, top, fontSize: fs, fontFamily: 'monospace', color: '#000', background: isCard ? '#e8e8e8' : 'transparent', border: isCard ? '1px solid #aaa' : 'none', padding: isCard ? '2px 4px' : 0, whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{text}
</div>
);
})
: elements.map((el) => renderEl(el, el.id))}
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { clearToken } from '../api/client';
const links = [
{ to: '/', label: 'Dashboard', exact: true },
{ to: '/mqtt/brokers', label: 'MQTT Broker' },
{ to: '/pg/sources', label: 'PostgreSQL Quellen' },
{ to: '/datapoints', label: 'Datenpunkte' },
{ to: '/displays', label: 'Displays' },
{ to: '/layouts', label: 'Layouts' },
];
export default function Sidebar() {
const navigate = useNavigate();
function logout() {
clearToken();
navigate('/login');
}
return (
<aside className="fixed top-0 left-0 h-full w-56 flex flex-col" style={{ backgroundColor: '#1a1a2e' }}>
<div className="px-4 py-5 border-b border-slate-700">
<span className="text-white font-bold text-lg tracking-wide">E-Paper Display</span>
</div>
<nav className="flex-1 overflow-y-auto py-4">
{links.map(({ to, label, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
className={({ isActive }) =>
`flex items-center px-4 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-sky-400 text-white'
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-slate-700">
<button
onClick={logout}
className="w-full text-sm text-slate-400 hover:text-white py-2 px-3 rounded hover:bg-slate-700 transition-colors text-left"
>
Abmelden
</button>
</div>
</aside>
);
}

View File

@ -0,0 +1,20 @@
interface StatusBadgeProps {
status: string | null | undefined;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const s = (status ?? '').toLowerCase();
let color = 'bg-gray-200 text-gray-700';
if (s === 'connected' || s === 'ok' || s === 'active') color = 'bg-green-100 text-green-800';
else if (s === 'error' || s === 'failed') color = 'bg-red-100 text-red-800';
else if (s === 'disconnected') color = 'bg-gray-200 text-gray-600';
else if (s === 'unknown') color = 'bg-yellow-100 text-yellow-800';
else if (s === 'stale') color = 'bg-orange-100 text-orange-800';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{status ?? 'unbekannt'}
</span>
);
}

View File

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Broker { id: number; name: string; status: string }
interface Datapoint { id: number; name: string; label: string; value: string | null; unit: string | null; source_type: string; status: string; updated_at: string | null }
interface Display { id: number; name: string; last_seen: string | null }
export default function Dashboard() {
const [brokers, setBrokers] = useState<Broker[]>([]);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [displays, setDisplays] = useState<Display[]>([]);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
api.get<Broker[]>('/api/mqtt/brokers'),
api.get<Datapoint[]>('/api/datapoints'),
api.get<Display[]>('/api/displays'),
])
.then(([b, d, disp]) => {
setBrokers(b);
setDatapoints(d);
setDisplays(disp);
})
.catch((err) => setError((err as Error).message));
}, []);
const lastUpdate = datapoints
.map((d) => d.updated_at)
.filter(Boolean)
.sort()
.at(-1);
const tiles = [
{ label: 'MQTT Broker', value: brokers.length, color: 'bg-blue-50 border-blue-200' },
{ label: 'Datenpunkte', value: datapoints.length, color: 'bg-green-50 border-green-200' },
{ label: 'Displays', value: displays.length, color: 'bg-purple-50 border-purple-200' },
{ label: 'Letztes Update', value: lastUpdate ? new Date(lastUpdate).toLocaleString('de') : '-', color: 'bg-yellow-50 border-yellow-200' },
];
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Dashboard</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{tiles.map(({ label, value, color }) => (
<div key={label} className={`rounded-lg border p-4 ${color}`}>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-bold text-gray-800">{value}</p>
</div>
))}
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200">
<h3 className="font-medium text-gray-700">Datenpunkte</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{datapoints.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td>
</tr>
)}
{datapoints.map((dp) => (
<tr key={dp.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{dp.label || dp.name}</td>
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
<td className="px-4 py-2 text-gray-500">{dp.unit ?? ''}</td>
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Datapoint {
id: number;
name: string;
label: string | null;
value: string | null;
unit: string | null;
source_type: string;
status: string;
updated_at: string | null;
}
export default function Datapoints() {
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [edits, setEdits] = useState<Record<number, { label: string; unit: string }>>({});
const [error, setError] = useState('');
async function load() {
try {
const data = await api.get<Datapoint[]>('/api/datapoints');
setDatapoints(data);
const e: Record<number, { label: string; unit: string }> = {};
data.forEach((dp) => { e[dp.id] = { label: dp.label ?? '', unit: dp.unit ?? '' }; });
setEdits(e);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleBlur(id: number, field: 'label' | 'unit') {
try {
await api.put(`/api/datapoints/${id}`, { [field]: edits[id]?.[field] || null });
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Datenpunkte</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Interner Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{datapoints.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td></tr>
)}
{datapoints.map((dp) => (
<tr key={dp.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs text-gray-600">{dp.name}</td>
<td className="px-4 py-2">
<input
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-full focus:outline-none"
value={edits[dp.id]?.label ?? ''}
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], label: e.target.value } })}
onBlur={() => handleBlur(dp.id, 'label')}
/>
</td>
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
<td className="px-4 py-2">
<input
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-20 focus:outline-none"
value={edits[dp.id]?.unit ?? ''}
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], unit: e.target.value } })}
onBlur={() => handleBlur(dp.id, 'unit')}
/>
</td>
<td className="px-4 py-2 text-gray-500 text-xs">{dp.source_type}</td>
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
interface Display {
id: number;
name: string;
layout_id: number | null;
api_token: string | null;
last_seen: string | null;
refresh_sec: number;
}
interface Layout { id: number; name: string }
const emptyForm = { name: '', layout_id: '', refresh_sec: '300' };
export default function Displays() {
const [displays, setDisplays] = useState<Display[]>([]);
const [layouts, setLayouts] = useState<Layout[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
async function load() {
try {
const [d, l] = await Promise.all([
api.get<Display[]>('/api/displays'),
api.get<Layout[]>('/api/layouts'),
]);
setDisplays(d);
setLayouts(l);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/displays', {
name: form.name,
layout_id: form.layout_id ? parseInt(form.layout_id) : null,
refresh_sec: parseInt(form.refresh_sec),
});
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleLayoutChange(id: number, layout_id: string) {
try {
await api.put(`/api/displays/${id}`, { layout_id: layout_id ? parseInt(layout_id) : null });
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Display wirklich loeschen?')) return;
try {
await api.del(`/api/displays/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Displays</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Refresh (s)</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">API URL</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Zuletzt gesehen</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displays.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
)}
{displays.map((d) => (
<tr key={d.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{d.name}</td>
<td className="px-4 py-2">
<select
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={d.layout_id ?? ''}
onChange={(e) => handleLayoutChange(d.id, e.target.value)}
>
<option value="">-- kein Layout --</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</td>
<td className="px-4 py-2 text-gray-600">{d.refresh_sec}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500">/api/display/{d.id}</td>
<td className="px-4 py-2 text-gray-500 text-xs">
{d.last_seen ? new Date(d.last_seen).toLocaleString('de') : '-'}
</td>
<td className="px-4 py-2">
<button onClick={() => handleDelete(d.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Display hinzufuegen</h3>
<form onSubmit={handleAdd} className="flex gap-4 flex-wrap items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Name *</label>
<input
required
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Layout</label>
<select
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
value={form.layout_id}
onChange={(e) => setForm({ ...form, layout_id: e.target.value })}
>
<option value="">-- kein Layout --</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Refresh (s)</label>
<input
type="number"
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-24"
value={form.refresh_sec}
onChange={(e) => setForm({ ...form, refresh_sec: e.target.value })}
/>
</div>
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
Hinzufuegen
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,273 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
import EpaperPreview from '../components/EpaperPreview';
interface Layout {
id: number;
name: string;
type: string;
width: number;
height: number;
}
interface LayoutElement {
id: number;
layout_id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
style: string;
static_text: string | null;
datapoint_id: number | null;
icon: string | null;
}
interface LayoutWithElements extends Layout {
elements: LayoutElement[];
}
interface Datapoint {
id: number;
name: string;
label: string | null;
}
const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static'];
const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' };
export default function Layouts() {
const [layouts, setLayouts] = useState<Layout[]>([]);
const [selected, setSelected] = useState<LayoutWithElements | null>(null);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [newName, setNewName] = useState('');
const [elForm, setElForm] = useState(emptyEl);
const [error, setError] = useState('');
async function loadLayouts() {
try {
setLayouts(await api.get<Layout[]>('/api/layouts'));
} catch (e) {
setError((e as Error).message);
}
}
async function selectLayout(id: number) {
try {
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
loadLayouts();
api.get<Datapoint[]>('/api/datapoints').then(setDatapoints).catch(() => {});
}, []);
async function handleAddLayout(e: FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
await api.post('/api/layouts', { name: newName.trim() });
setNewName('');
loadLayouts();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDeleteLayout(id: number) {
if (!confirm('Layout wirklich loeschen?')) return;
try {
await api.del(`/api/layouts/${id}`);
if (selected?.id === id) setSelected(null);
loadLayouts();
} catch (e) {
setError((e as Error).message);
}
}
async function handleAddElement(e: FormEvent) {
e.preventDefault();
if (!selected) return;
try {
await api.post(`/api/layouts/${selected.id}/elements`, {
type: elForm.type,
x: parseInt(elForm.x),
y: parseInt(elForm.y),
font_size: parseInt(elForm.font_size),
style: elForm.style,
datapoint_id: elForm.datapoint_id ? parseInt(elForm.datapoint_id) : null,
icon: elForm.icon || null,
static_text: elForm.static_text || null,
});
setElForm(emptyEl);
selectLayout(selected.id);
} catch (e) {
setError((e as Error).message);
}
}
async function handleDeleteElement(elId: number) {
if (!selected) return;
try {
await api.del(`/api/layouts/${selected.id}/elements/${elId}`);
selectLayout(selected.id);
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Layouts</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="flex gap-6">
{/* Left: layout list */}
<div className="w-64 shrink-0">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3">
{layouts.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-400">Keine Layouts vorhanden</p>
)}
{layouts.map((l) => (
<div
key={l.id}
onClick={() => selectLayout(l.id)}
className={`flex items-center justify-between px-3 py-2.5 cursor-pointer text-sm border-b border-gray-100 last:border-0 ${selected?.id === l.id ? 'bg-sky-50 text-sky-700 font-medium' : 'hover:bg-gray-50'}`}
>
<span>{l.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }}
className="text-red-400 hover:text-red-600 text-xs ml-2"
>
X
</button>
</div>
))}
</div>
<form onSubmit={handleAddLayout} className="flex gap-2">
<input
placeholder="Neues Layout..."
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
</form>
</div>
{/* Right: element editor */}
{selected && (
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-700 mb-4">
Elemente in <span className="text-sky-600">{selected.name}</span>{' '}
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span>
</h3>
{/* Element list */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">X / Y</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Schriftgroesse</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Datenpunkt</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Statischer Text</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{selected.elements.length === 0 && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-400 text-xs">Noch keine Elemente</td></tr>
)}
{selected.elements.map((el) => {
const dp = datapoints.find((d) => d.id === el.datapoint_id);
return (
<tr key={el.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-xs font-mono">{el.type}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.x} / {el.y}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.font_size}</td>
<td className="px-3 py-2 text-xs text-gray-600">{dp ? (dp.label || dp.name) : '-'}</td>
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate">{el.static_text ?? '-'}</td>
<td className="px-3 py-2">
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Add element form */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4>
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Typ</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.type} onChange={(e) => setElForm({ ...elForm, type: e.target.value })}>
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">X</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.x} onChange={(e) => setElForm({ ...elForm, x: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Y</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.y} onChange={(e) => setElForm({ ...elForm, y: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.font_size} onChange={(e) => setElForm({ ...elForm, font_size: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Stil</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.style} onChange={(e) => setElForm({ ...elForm, style: e.target.value })}>
<option value="plain">plain</option>
<option value="card">card</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.datapoint_id} onChange={(e) => setElForm({ ...elForm, datapoint_id: e.target.value })}>
<option value="">-- keiner --</option>
{datapoints.map((dp) => (
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Icon</label>
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.icon} onChange={(e) => setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" />
</div>
<div className="col-span-2">
<label className="block text-xs text-gray-600 mb-1">Statischer Text</label>
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.static_text} onChange={(e) => setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" />
</div>
<div className="col-span-3">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-4 py-2 rounded transition-colors">Element hinzufuegen</button>
</div>
</form>
</div>
{/* E-paper preview */}
<EpaperPreview
layoutId={selected.id}
elements={selected.elements}
layoutWidth={selected.width}
layoutHeight={selected.height}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, setToken } from '../api/client';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api.post<{ token: string }>('/api/auth/login', { username, password });
setToken(res.token);
navigate('/');
} catch (err) {
setError((err as Error).message || 'Anmeldung fehlgeschlagen');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">E-Paper Display</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400"
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-sky-500 hover:bg-sky-600 text-white font-medium py-2 rounded transition-colors disabled:opacity-50"
>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Broker {
id: number;
name: string;
host: string;
port: number;
username: string | null;
password: string | null;
use_tls: number;
status: string;
}
const emptyForm = { name: '', host: '', port: '1883', username: '', password: '', use_tls: false };
export default function MqttBrokers() {
const [brokers, setBrokers] = useState<Broker[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
const [testMsg, setTestMsg] = useState('');
const [loading, setLoading] = useState(false);
async function load() {
try {
setBrokers(await api.get<Broker[]>('/api/mqtt/brokers'));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/mqtt/brokers', {
...form,
port: parseInt(form.port),
use_tls: form.use_tls ? 1 : 0,
username: form.username || null,
password: form.password || null,
});
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Broker wirklich loeschen?')) return;
try {
await api.del(`/api/mqtt/brokers/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleTest() {
setTestMsg('');
setLoading(true);
try {
const res = await api.post<{ success: boolean }>('/api/mqtt/brokers/test', {
...form,
port: parseInt(form.port),
use_tls: form.use_tls ? 1 : 0,
});
setTestMsg(res.success ? 'Verbindung erfolgreich!' : 'Verbindung fehlgeschlagen.');
} catch (e) {
setTestMsg('Fehler: ' + (e as Error).message);
} finally {
setLoading(false);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">MQTT Broker</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
{/* Broker list */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Host:Port</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{brokers.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Broker konfiguriert</td></tr>
)}
{brokers.map((b) => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{b.name}</td>
<td className="px-4 py-2 text-gray-600">{b.host}:{b.port}</td>
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
<td className="px-4 py-2 flex gap-2">
<Link to={`/mqtt/topics/${b.id}`} className="text-sky-600 hover:underline text-xs">Topics</Link>
<button onClick={() => handleDelete(b.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Broker hinzufuegen</h3>
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Name *</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Host *</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Port</label>
<input type="number" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.port} onChange={(e) => setForm({ ...form, port: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Benutzername</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Passwort</label>
<input type="password" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div className="flex items-center gap-2 mt-5">
<input type="checkbox" id="tls" checked={form.use_tls} onChange={(e) => setForm({ ...form, use_tls: e.target.checked })} />
<label htmlFor="tls" className="text-sm text-gray-700">TLS verwenden</label>
</div>
{testMsg && (
<div className={`col-span-2 text-sm p-2 rounded ${testMsg.includes('erfolgreich') ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>{testMsg}</div>
)}
<div className="col-span-2 flex gap-3">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">Hinzufuegen</button>
<button type="button" onClick={handleTest} disabled={loading} className="border border-gray-300 hover:bg-gray-50 text-sm px-4 py-2 rounded transition-colors disabled:opacity-50">Verbindung testen</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../api/client';
interface Topic {
id: number;
broker_id: number;
topic: string;
last_payload: string | null;
json_path: string | null;
ignored: number;
discovered_at: string;
}
export default function MqttTopics() {
const { brokerId } = useParams<{ brokerId: string }>();
const [topics, setTopics] = useState<Topic[]>([]);
const [filter, setFilter] = useState('');
const [prefix, setPrefix] = useState('');
const [error, setError] = useState('');
const [discoveryMsg, setDiscoveryMsg] = useState('');
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
async function load() {
try {
const data = await api.get<Topic[]>(`/api/mqtt/brokers/${brokerId}/topics`);
setTopics(data);
const paths: Record<number, string> = {};
data.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
setEditPaths(paths);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, [brokerId]);
async function handleDiscover() {
setDiscoveryMsg('');
try {
await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null });
setDiscoveryMsg('Discovery gestartet. Topics erscheinen nach einigen Sekunden.');
setTimeout(() => load(), 3000);
} catch (e) {
setError((e as Error).message);
}
}
async function handleIgnore(id: number, ignored: number) {
try {
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: ignored ? 0 : 1 });
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleJsonPathBlur(id: number) {
try {
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { json_path: editPaths[id] || null });
} catch (e) {
setError((e as Error).message);
}
}
const filtered = topics.filter((t) =>
!t.ignored && t.topic.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
<p className="text-sm text-gray-500 mb-6">Broker ID: {brokerId}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
{/* Controls */}
<div className="flex gap-3 mb-6 flex-wrap">
<input
placeholder="Filter Topics..."
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-56"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<input
placeholder="Discovery-Praefix (z.B. sensor/)"
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-64"
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
/>
<button
onClick={handleDiscover}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm px-4 py-1.5 rounded transition-colors"
>
Discovery starten
</button>
</div>
{discoveryMsg && <p className="text-sm text-indigo-600 mb-4">{discoveryMsg}</p>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Topic</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Letzter Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">JSON-Pfad</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Topics gefunden</td></tr>
)}
{filtered.map((t) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs text-gray-700">{t.topic}</td>
<td className="px-4 py-2 text-xs text-gray-500 max-w-xs truncate">{t.last_payload ?? '-'}</td>
<td className="px-4 py-2">
<input
className="border border-gray-300 rounded px-2 py-1 text-xs w-40"
value={editPaths[t.id] ?? ''}
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
onBlur={() => handleJsonPathBlur(t.id)}
placeholder="$.temperature"
/>
</td>
<td className="px-4 py-2">
<button
onClick={() => handleIgnore(t.id, t.ignored)}
className="text-xs text-orange-500 hover:text-orange-700"
>
Ignorieren
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface PgSource {
id: number;
name: string;
host: string;
port: number;
database_name: string;
username: string;
status: string;
}
const emptyForm = { name: '', host: '', port: '5432', database_name: '', username: '', password: '' };
export default function PgSources() {
const [sources, setSources] = useState<PgSource[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
async function load() {
try {
setSources(await api.get<PgSource[]>('/api/pg/sources'));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/pg/sources', { ...form, port: parseInt(form.port) });
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Quelle wirklich loeschen?')) return;
try {
await api.del(`/api/pg/sources/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">PostgreSQL Quellen</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Host:Port/DB</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sources.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Quellen konfiguriert</td></tr>
)}
{sources.map((s) => (
<tr key={s.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{s.name}</td>
<td className="px-4 py-2 text-gray-600">{s.host}:{s.port}/{s.database_name}</td>
<td className="px-4 py-2"><StatusBadge status={s.status} /></td>
<td className="px-4 py-2">
<button onClick={() => handleDelete(s.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Quelle hinzufuegen</h3>
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
{[
{ key: 'name', label: 'Name *', type: 'text' },
{ key: 'host', label: 'Host *', type: 'text' },
{ key: 'port', label: 'Port', type: 'number' },
{ key: 'database_name', label: 'Datenbank *', type: 'text' },
{ key: 'username', label: 'Benutzername *', type: 'text' },
{ key: 'password', label: 'Passwort *', type: 'password' },
].map(({ key, label, type }) => (
<div key={key}>
<label className="block text-xs text-gray-600 mb-1">{label}</label>
<input
type={type}
required={label.includes('*')}
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
value={(form as Record<string, string>)[key]}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</div>
))}
<div className="col-span-2">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
Hinzufuegen
</button>
</div>
</form>
</div>
</div>
);
}