Compare commits

...

4 Commits

Author SHA1 Message Date
admin 1a34fccc35 v1.7.2: Zeiteingabe – Startzeit leer, Endzeit vorbelegt und fokussiert
- Startzeit startet leer (manuelle Eingabe erforderlich)
- Endzeit startet mit aktueller Uhrzeit auf 5 Minuten aufgerundet
- Endzeit erhält Autofokus beim Laden des Formulars
- Startzeit synct Endzeit nicht mehr automatisch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:01:09 +02:00
admin cf038ad3be fix: Temperatureingabe negativ, Monatsauswahl auf aktuellem Monat begrenzt
- LogbuchForm: Temperatur als String-State (tempRaw) – Minus-Vorzeichen tippbar
- LogbuchList: input[type=month] mit max=currentMonth(), kein Zukunftsmonat wählbar
- .gitignore: DB_BACKUP/ ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:47:44 +02:00
admin 4593713042 feat: ObjektSelector – Dropdown bleibt nach Auswahl offen
Mehrere Objekte können ohne Schließen des Dropdowns ausgewählt werden.
Fokus kehrt nach jeder Auswahl ins Suchfeld zurück.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:47:08 +02:00
admin edb324719b 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>
2026-05-14 20:36:56 +02:00
12 changed files with 382 additions and 75 deletions
+1
View File
@@ -40,3 +40,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
backup.git/ backup.git/
DB_BACKUP/
+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 { 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
View File
@@ -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 */}
&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. {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">
&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> </main>
</div> </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 { 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 });
}
}
+33 -24
View File
@@ -20,10 +20,18 @@ function toLocalDatetimeValue(isoOrDatetime: string): string {
return isoOrDatetime.slice(0, 16); return isoOrDatetime.slice(0, 16);
} }
function nowLocalDatetime(): string { function todayDate(): string {
const now = new Date(); const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function nowRounded5(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const total = now.getHours() * 60 + now.getMinutes();
const rounded = Math.ceil(total / 5) * 5;
return `${pad(Math.floor(rounded / 60) % 24)}:${pad(rounded % 60)}`;
} }
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD']; const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
@@ -31,14 +39,15 @@ const SONNE_ART: ArtFuehrung = 'SonF';
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) { export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF'); const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF');
const [beginn, setBeginn] = useState(nowLocalDatetime()); const [beginn, setBeginn] = useState(todayDate());
const [ende, setEnde] = useState(nowLocalDatetime()); const [ende, setEnde] = useState(todayDate() + 'T' + nowRounded5());
const [besucher, setBesucher] = useState<number | ''>(''); const [besucher, setBesucher] = useState<number | ''>('');
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]); const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]); const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
const [bemerkungen, setBemerkungen] = useState(''); const [bemerkungen, setBemerkungen] = useState('');
const [sonderName, setSonderName] = useState(''); const [sonderName, setSonderName] = useState('');
const [wetter, setWetter] = useState<Wetter>({ temp: 0, feuchte: 0, druck: 0 }); const [wetter, setWetter] = useState<Wetter>({ temp: 0, feuchte: 0, druck: 0 });
const [tempRaw, setTempRaw] = useState('0');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
@@ -68,6 +77,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.then((w: Wetter) => { .then((w: Wetter) => {
if (!editEntryRef.current) { if (!editEntryRef.current) {
setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) }); setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) });
setTempRaw(String(w.temp));
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -82,16 +92,14 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBemerkungen(editEntry.Bemerkungen ?? ''); setBemerkungen(editEntry.Bemerkungen ?? '');
setSonderName(editEntry.SonderName ?? ''); setSonderName(editEntry.SonderName ?? '');
if (editEntry.WetterTemp !== null) { if (editEntry.WetterTemp !== null) {
setWetter({ const t = editEntry.WetterTemp ?? 0;
temp: editEntry.WetterTemp ?? 0, setWetter({ temp: t, feuchte: Math.round(editEntry.WetterFeuchte ?? 0), druck: Math.round(editEntry.WetterDruck ?? 0) });
feuchte: Math.round(editEntry.WetterFeuchte ?? 0), setTempRaw(String(t));
druck: Math.round(editEntry.WetterDruck ?? 0),
});
} }
} else { } else {
setArtFuehrung('RF'); setArtFuehrung('RF');
setBeginn(nowLocalDatetime()); setBeginn(todayDate());
setEnde(nowLocalDatetime()); setEnde(todayDate() + 'T' + nowRounded5());
setBesucher(0); setBesucher(0);
setBeos([currentUserBeo]); setBeos([currentUserBeo]);
setObjekte([]); setObjekte([]);
@@ -144,8 +152,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setError('Bitte Besucherzahl eingeben.'); setError('Bitte Besucherzahl eingeben.');
return; return;
} }
if (!beginn.slice(11, 16)) {
setError('Bitte Startzeit eingeben.');
return;
}
if (beginn === ende) { if (beginn === ende) {
setError('Die Zeite wurden nicht eingegeben'); setError('Start- und Endzeit sind identisch.');
return; return;
} }
@@ -161,7 +173,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
beoIds: beos.map((b) => b.ID), beoIds: beos.map((b) => b.ID),
objekte: showObjekte ? objekte : [], objekte: showObjekte ? objekte : [],
Bemerkungen: bemerkungen, Bemerkungen: bemerkungen,
Wetter: wetter, Wetter: { ...wetter, temp: parseFloat(tempRaw) || 0 },
}; };
const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch'; const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch';
@@ -177,8 +189,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setSuccess(true); setSuccess(true);
setTimeout(() => setSuccess(false), 5000); setTimeout(() => setSuccess(false), 5000);
if (!editEntry) { if (!editEntry) {
setBeginn(nowLocalDatetime()); setBeginn(todayDate());
setEnde(nowLocalDatetime()); setEnde(todayDate() + 'T' + nowRounded5());
setBesucher(0); setBesucher(0);
setBeos([currentUserBeo]); setBeos([currentUserBeo]);
setObjekte([]); setObjekte([]);
@@ -238,10 +250,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<label className={labelCls}>Startzeit</label> <label className={labelCls}>Startzeit</label>
<TimeInput <TimeInput
value={beginn.slice(11, 16)} value={beginn.slice(11, 16)}
onChange={(t) => { onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
setBeginn(beginn.slice(0, 10) + 'T' + t);
setEnde(ende.slice(0, 10) + 'T' + t);
}}
className="w-24" className="w-24"
/> />
</div> </div>
@@ -250,7 +259,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<TimeInput <TimeInput
value={ende.slice(11, 16)} value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)} onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
clearOnFocus autoFocus
className="w-24" className="w-24"
/> />
</div> </div>
@@ -328,10 +337,10 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<div> <div>
<label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label> <label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label>
<input <input
type="number" type="text"
value={wetter.temp} inputMode="decimal"
onChange={(e) => setWetter({ ...wetter, temp: parseFloat(e.target.value) || 0 })} value={tempRaw}
step="0.1" onChange={(e) => setTempRaw(e.target.value)}
className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none" className="w-1/3 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
+2 -1
View File
@@ -117,7 +117,8 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, co
<input <input
type="month" type="month"
value={month} value={month}
onChange={(e) => setMonth(e.target.value)} max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-gray-300 rounded-lg px-2 py-1 text-sm" className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
/> />
<button <button
+5 -3
View File
@@ -13,6 +13,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
fetch('/api/objekte') fetch('/api/objekte')
@@ -45,7 +46,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
function add(obj: ObjektOption) { function add(obj: ObjektOption) {
onChange([...selected, { ID: obj.ID, Name: obj.Name }]); onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
setSearch(''); setSearch('');
setDropdownOpen(false); inputRef.current?.focus();
} }
function addNew(name: string) { function addNew(name: string) {
@@ -58,7 +59,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
onChange([...selected, { ID: null, Name: trimmed }]); onChange([...selected, { ID: null, Name: trimmed }]);
} }
setSearch(''); setSearch('');
setDropdownOpen(false); inputRef.current?.focus();
} }
function remove(name: string) { function remove(name: string) {
@@ -98,6 +99,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
<div ref={wrapperRef} className="relative"> <div ref={wrapperRef} className="relative">
<input <input
ref={inputRef}
type="text" type="text"
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }} onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
@@ -124,7 +126,7 @@ export default function ObjektSelector({ selected, onChange }: Props) {
onClick={() => addNew(searchTrimmed)} onClick={() => addNew(searchTrimmed)}
className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium" className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium"
> >
+ {searchTrimmed}" hinzufügen + &bdquo;{searchTrimmed}&ldquo; hinzufügen
</button> </button>
)} )}
</div> </div>
+3 -1
View File
@@ -7,6 +7,7 @@ interface Props {
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
clearOnFocus?: boolean; clearOnFocus?: boolean;
autoFocus?: boolean;
} }
function isValid(t: string): boolean { function isValid(t: string): boolean {
@@ -20,7 +21,7 @@ function normalize(t: string): string {
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
} }
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false }: Props) { export default function TimeInput({ value, onChange, className = '', clearOnFocus = false, autoFocus = false }: Props) {
const [local, setLocal] = useState(value); const [local, setLocal] = useState(value);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@@ -71,6 +72,7 @@ export default function TimeInput({ value, onChange, className = '', clearOnFocu
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus={autoFocus}
placeholder="HH:MM" placeholder="HH:MM"
maxLength={5} maxLength={5}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${ className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.1", "version": "1.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "logbuch", "name": "logbuch",
"version": "1.7.1", "version": "1.7.2",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.2.2", "jose": "^6.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.7.1", "version": "1.7.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",