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 { getSession } from '@/lib/session';
|
||||||
import { query } from '@/lib/db';
|
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 {
|
export interface BeoUser {
|
||||||
id: number;
|
id: number;
|
||||||
kürzel: string | null;
|
kürzel: string | null;
|
||||||
|
|||||||
+84
-42
@@ -1,64 +1,106 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
import { listUsers } from './actions';
|
import { listUsers, listObjekte } from './actions';
|
||||||
import ResetButton from './ResetButton';
|
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();
|
const session = await getSession();
|
||||||
if (!session) redirect('/login');
|
if (!session) redirect('/login');
|
||||||
if (session.role === null || !session.role.includes('admin')) redirect('/');
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-white py-4 px-4">
|
<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]">
|
<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>
|
<h1 className="text-3xl font-bold">Logbuch — Sternwarte Welzheim</h1>
|
||||||
<Link href="/" className="text-sm text-blue-600 hover:underline">← Zurück</Link>
|
<Link href="/" className="text-sm text-blue-600 hover:underline">← Zurück</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Benutzerverwaltung</h2>
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 border-b border-gray-200">
|
||||||
<div className="bg-white border border-gray-300 rounded-xl shadow-sm overflow-hidden">
|
{tabs.map(({ id, label }) => (
|
||||||
<table className="w-full text-sm">
|
<Link
|
||||||
<thead className="bg-gray-100 text-gray-700">
|
key={id}
|
||||||
<tr>
|
href={`/admin?tab=${id}`}
|
||||||
<th className="text-left px-4 py-3 font-semibold">Kürzel</th>
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
<th className="text-left px-4 py-3 font-semibold">Name</th>
|
activeTab === id
|
||||||
<th className="text-left px-4 py-3 font-semibold">Vorname</th>
|
? 'border-[#85B7D7] text-gray-900'
|
||||||
<th className="text-left px-4 py-3 font-semibold">Rolle</th>
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
<th className="text-left px-4 py-3 font-semibold">Passwort</th>
|
}`}
|
||||||
<th className="px-4 py-3"></th>
|
>
|
||||||
</tr>
|
{label}
|
||||||
</thead>
|
</Link>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-gray-500">
|
{/* Benutzerverwaltung */}
|
||||||
„Zurücksetzen“ setzt das Passwort auf NULL. Der Benutzer muss sich danach mit dem Standard-Passwort anmelden und es sofort ändern.
|
{activeTab === 'benutzer' && (
|
||||||
</p>
|
<>
|
||||||
|
<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>
|
</main>
|
||||||
</div>
|
</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 { query } from '@/lib/db';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
@@ -13,3 +13,19 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
|
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