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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
„Zurücksetzen“ setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort ä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">
|
||||
„Zurücksetzen“ setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort ändern.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Objektverwaltung */}
|
||||
{activeTab === 'objekte' && (
|
||||
<ObjekteManager initialObjekte={objekte} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user