feat: Admin – Objektverwaltung mit Tab-Navigation

Admins können Objekte anlegen, umbenennen und löschen.
Die Admin-Seite ist in zwei Tabs aufgeteilt: Benutzerverwaltung (?tab=benutzer)
und Objektverwaltung (?tab=objekte), navigierbar per URL-Parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 20:36:56 +02:00
parent 845b634804
commit edb324719b
5 changed files with 335 additions and 43 deletions
+183
View File
@@ -0,0 +1,183 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ObjektRow } from './actions';
interface Props {
initialObjekte: ObjektRow[];
}
export default function ObjekteManager({ initialObjekte }: Props) {
const router = useRouter();
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState('');
const [newName, setNewName] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function handleSaveEdit(id: number) {
const trimmed = editName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Speichern.');
return;
}
setEditingId(null);
router.refresh();
}
async function handleDelete(id: number, name: string) {
if (!confirm(`Objekt „${name}" wirklich löschen?`)) return;
setBusy(true);
setError('');
const res = await fetch('/api/objekte/' + id, { method: 'DELETE' });
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Löschen.');
return;
}
router.refresh();
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) { setError('Name darf nicht leer sein.'); return; }
setBusy(true);
setError('');
const res = await fetch('/api/objekte', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
setBusy(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? 'Fehler beim Erstellen.');
return;
}
setNewName('');
router.refresh();
}
return (
<div>
{error && (
<p className="mb-3 text-sm text-red-600 font-medium">{error}</p>
)}
<form onSubmit={handleCreate} className="flex gap-2 mb-4">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Neues Objekt…"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-blue-500"
/>
<button
type="submit"
disabled={busy}
className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Hinzufügen
</button>
</form>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold hidden sm:table-cell">Zuletzt verwendet</th>
<th className="px-4 py-3 w-36"></th>
</tr>
</thead>
<tbody>
{initialObjekte.map((obj, idx) => (
<tr key={obj.ID} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-2 text-gray-400 font-mono text-xs">{obj.ID}</td>
<td className="px-4 py-2">
{editingId === obj.ID ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(obj.ID);
if (e.key === 'Escape') setEditingId(null);
}}
autoFocus
className="w-full px-2 py-1 border border-blue-400 rounded text-sm focus:outline-none"
/>
) : (
obj.Name
)}
</td>
<td className="px-4 py-2 text-gray-500 hidden sm:table-cell">
{obj.LastUsed ? new Date(obj.LastUsed).toLocaleDateString('de-DE') : '—'}
</td>
<td className="px-4 py-2 text-right">
{editingId === obj.ID ? (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleSaveEdit(obj.ID)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 border border-green-300 rounded hover:bg-green-200 disabled:opacity-50"
>
Speichern
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300 rounded hover:bg-gray-200"
>
Abbrechen
</button>
</span>
) : (
<span className="flex justify-end gap-2">
<button
type="button"
onClick={() => { setEditingId(obj.ID); setEditName(obj.Name); setError(''); }}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-700 border border-blue-300 rounded hover:bg-blue-200 disabled:opacity-50"
>
Bearbeiten
</button>
<button
type="button"
onClick={() => handleDelete(obj.ID, obj.Name)}
disabled={busy}
className="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
Löschen
</button>
</span>
)}
</td>
</tr>
))}
{initialObjekte.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400 text-sm">Keine Objekte vorhanden.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
+13
View File
@@ -4,6 +4,19 @@ import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { query } from '@/lib/db';
export interface ObjektRow {
ID: number;
Name: string;
LastUsed: string | null;
}
export async function listObjekte(): Promise<ObjektRow[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
const rows = await query('SELECT ID, Name, LastUsed FROM objekte ORDER BY Name ASC', []);
return rows as ObjektRow[];
}
export interface BeoUser {
id: number;
kürzel: string | null;
+84 -42
View File
@@ -1,64 +1,106 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { listUsers } from './actions';
import { listUsers, listObjekte } from './actions';
import ResetButton from './ResetButton';
import ObjekteManager from './ObjekteManager';
export default async function AdminPage() {
type Tab = 'benutzer' | 'objekte';
export default async function AdminPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const session = await getSession();
if (!session) redirect('/login');
if (session.role === null || !session.role.includes('admin')) redirect('/');
const users = await listUsers();
const { tab: tabParam } = await searchParams;
const activeTab: Tab = tabParam === 'objekte' ? 'objekte' : 'benutzer';
const [users, objekte] = await Promise.all([
activeTab === 'benutzer' ? listUsers() : Promise.resolve([]),
activeTab === 'objekte' ? listObjekte() : Promise.resolve([]),
]);
const tabs: { id: Tab; label: string }[] = [
{ id: 'benutzer', label: 'Benutzerverwaltung' },
{ id: 'objekte', label: 'Objektverwaltung' },
];
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<div className="flex justify-between items-center mb-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
<Link href="/" className="text-sm text-blue-600 hover:underline"> Zurück</Link>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Benutzerverwaltung</h2>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.hasPw ? (
<span className="text-green-700">gesetzt</span>
) : (
<span className="text-amber-600 font-medium">Standard</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200">
{tabs.map(({ id, label }) => (
<Link
key={id}
href={`/admin?tab=${id}`}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === id
? 'border-[#85B7D7] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{label}
</Link>
))}
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
{/* Benutzerverwaltung */}
{activeTab === 'benutzer' && (
<>
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
<th className="text-left px-4 py-3 font-semibold">Name</th>
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user, idx) => (
<tr key={user.id} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-3 font-mono">{user.kürzel ?? '—'}</td>
<td className="px-4 py-3">{user.name}</td>
<td className="px-4 py-3">{user.vorname ?? '—'}</td>
<td className="px-4 py-3">{user.role ?? '—'}</td>
<td className="px-4 py-3">
{user.hasPw ? (
<span className="text-green-700">gesetzt</span>
) : (
<span className="text-amber-600 font-medium">Standard</span>
)}
</td>
<td className="px-4 py-3 text-right">
<ResetButton userId={user.id} userName={`${user.vorname ?? ''} ${user.name}`.trim()} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-xs text-gray-500">
&bdquo;Zur&uuml;cksetzen&ldquo; setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort &auml;ndern.
</p>
</>
)}
{/* Objektverwaltung */}
{activeTab === 'objekte' && (
<ObjekteManager initialObjekte={objekte} />
)}
</main>
</div>
);
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
const { name } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
await query('UPDATE objekte SET Name = ? WHERE ID = ?', [trimmed, numId]);
return NextResponse.json({ ID: numId, Name: trimmed });
} catch (error) {
console.error('PUT /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { id } = await params;
const numId = Number(id);
if (isNaN(numId)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 });
await query('DELETE FROM objekte WHERE ID = ?', [numId]);
return NextResponse.json({ ok: true });
} catch (error) {
console.error('DELETE /api/objekte/[id]:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+17 -1
View File
@@ -1,4 +1,4 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
@@ -13,3 +13,19 @@ export async function GET() {
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
if (!session.role?.includes('admin')) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 });
try {
const { name } = await req.json();
const trimmed = (name as string)?.trim();
if (!trimmed) return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 });
const result = await query('INSERT INTO objekte (Name) VALUES (?)', [trimmed]) as { insertId: number };
return NextResponse.json({ ID: result.insertId, Name: trimmed }, { status: 201 });
} catch (error) {
console.error('POST /api/objekte:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}