From 4e53a7a5cd76cb80994d43abea9cd589f3a16a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Mon, 27 Apr 2026 17:11:27 +0200 Subject: [PATCH] Initial implementation: Logbuch Sternwarte Welzheim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vollständige Next.js 16 Webanwendung als Logbuch für die Sternwarte Welzheim. 4 Kuppeln (West/Ost/Süd/Pluto), BEO-basierte Authentifizierung mit erzwungenem Passwort-Wechsel beim Erstlogin, MySQL-Backend, Docker-Deployment. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 38 +++++ app/MainClient.tsx | 135 +++++++++++++++++ app/api/beos/route.ts | 12 ++ app/api/logbuch/[id]/route.ts | 79 ++++++++++ app/api/logbuch/route.ts | 92 +++++++++++ app/api/logout/route.ts | 7 + app/api/objekte/route.ts | 12 ++ app/api/wetter/route.ts | 8 + app/change-password/actions.ts | 41 +++++ app/change-password/page.tsx | 112 ++++++++++++++ app/layout.tsx | 33 +--- app/login/actions.ts | 37 +++++ app/login/page.tsx | 110 ++++++++++++++ app/page.tsx | 73 ++------- claude/Logbuch.md | 42 +++++ components/BeoSelector.tsx | 70 +++++++++ components/LogbuchForm.tsx | 270 +++++++++++++++++++++++++++++++++ components/LogbuchList.tsx | 121 +++++++++++++++ components/ObjektSelector.tsx | 112 ++++++++++++++ create_table.sql | 48 ++++++ deploy.sh | 48 ++++++ lib/auth.ts | 38 +++++ lib/db.ts | 27 ++++ lib/session.ts | 62 ++++++++ next.config.ts | 21 ++- package-lock.json | 165 +++++++++++++++++++- package.json | 8 +- proxy.ts | 42 +++++ types/logbuch.ts | 61 ++++++++ 29 files changed, 1827 insertions(+), 97 deletions(-) create mode 100644 Dockerfile create mode 100644 app/MainClient.tsx create mode 100644 app/api/beos/route.ts create mode 100644 app/api/logbuch/[id]/route.ts create mode 100644 app/api/logbuch/route.ts create mode 100644 app/api/logout/route.ts create mode 100644 app/api/objekte/route.ts create mode 100644 app/api/wetter/route.ts create mode 100644 app/change-password/actions.ts create mode 100644 app/change-password/page.tsx create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx create mode 100644 claude/Logbuch.md create mode 100644 components/BeoSelector.tsx create mode 100644 components/LogbuchForm.tsx create mode 100644 components/LogbuchList.tsx create mode 100644 components/ObjektSelector.tsx create mode 100644 create_table.sql create mode 100755 deploy.sh create mode 100644 lib/auth.ts create mode 100644 lib/db.ts create mode 100644 lib/session.ts create mode 100644 proxy.ts create mode 100644 types/logbuch.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..152ef97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM node:22-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG BUILD_DATE +ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/MainClient.tsx b/app/MainClient.tsx new file mode 100644 index 0000000..4248dd7 --- /dev/null +++ b/app/MainClient.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useState } from 'react'; +import { KUPPELN } from '@/types/logbuch'; +import type { Kuppel, LogbuchEintrag } from '@/types/logbuch'; +import LogbuchForm from '@/components/LogbuchForm'; +import LogbuchList from '@/components/LogbuchList'; +import packageJson from '@/package.json'; + +interface Props { + kuerzel: string; + beoId: number; + beoName: string; +} + +export default function MainClient({ kuerzel, beoId, beoName }: Props) { + const [activeKuppel, setActiveKuppel] = useState('West'); + const [activeTab, setActiveTab] = useState<'eingabe' | 'liste'>('eingabe'); + const [refreshKey, setRefreshKey] = useState(0); + const [editEntry, setEditEntry] = useState(null); + + const version = packageJson.version; + const buildDate = + process.env.NEXT_PUBLIC_BUILD_DATE || + new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + const currentUserBeo = { ID: beoId, Kuerzel: kuerzel, Name: beoName }; + + function handleSaved() { + setRefreshKey((k) => k + 1); + setEditEntry(null); + if (editEntry) setActiveTab('liste'); + } + + function handleEdit(entry: LogbuchEintrag) { + setEditEntry(entry); + setActiveTab('eingabe'); + } + + async function handleLogout() { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/login'; + } + + return ( +
+
+
+

Logbuch — Sternwarte Welzheim

+
+ + {kuerzel} — {beoName} + + +
+
+ + {/* Kuppel-Tabs */} +
+ {KUPPELN.map((k) => ( + + ))} +
+ + {/* Eingabe/Liste-Tabs */} +
+ {(['eingabe', 'liste'] as const).map((tab) => ( + + ))} +
+ + {activeTab === 'eingabe' && ( +
+ {editEntry && ( +
+ Eintrag bearbeiten (ID {editEntry.ID}) +
+ )} + +
+ )} + + {activeTab === 'liste' && ( + + )} + + +
+
+ ); +} diff --git a/app/api/beos/route.ts b/app/api/beos/route.ts new file mode 100644 index 0000000..47bc659 --- /dev/null +++ b/app/api/beos/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET() { + try { + const rows = await query('SELECT ID, Kuerzel, Name FROM beos ORDER BY Name ASC'); + return NextResponse.json(rows); + } catch (error) { + console.error('GET /api/beos:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/api/logbuch/[id]/route.ts b/app/api/logbuch/[id]/route.ts new file mode 100644 index 0000000..29494ee --- /dev/null +++ b/app/api/logbuch/[id]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query, getPool } from '@/lib/db'; +import { getSession } from '@/lib/session'; +import type { SelectedObjekt } from '@/types/logbuch'; + +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const { id } = await params; + const logbuchId = parseInt(id); + + try { + const body = await request.json(); + const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; + + await getPool().execute( + `UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, Beginn=?, Ende=?, Besucher=?, + Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? + WHERE ID=?`, + [ + Kuppel, ArtFuehrung, Beginn, Ende, + Besucher ?? 0, + Bemerkungen?.slice(0, 500) || null, + Wetter?.temp ?? null, + Wetter?.feuchte ?? null, + Wetter?.druck ?? null, + logbuchId, + ] + ); + + await query('DELETE FROM logbuch_beos WHERE LogbuchID = ?', [logbuchId]); + await query('DELETE FROM logbuch_objekte WHERE LogbuchID = ?', [logbuchId]); + + for (const beoId of (beoIds as number[]) || []) { + await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]); + } + + for (const obj of (objekte as SelectedObjekt[]) || []) { + let objektId = obj.ID; + if (!objektId) { + const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; + if (existing[0]) { + objektId = existing[0].ID; + } else { + const [ins] = await getPool().execute( + 'INSERT INTO objekte (Name) VALUES (?)', [obj.Name] + ) as [{ insertId: number }, unknown]; + objektId = ins.insertId; + } + } + await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); + await query( + 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', + [logbuchId, objektId, obj.Name] + ); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('PUT /api/logbuch/[id]:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const { id } = await params; + + try { + await query('DELETE FROM logbuch WHERE ID = ?', [parseInt(id)]); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('DELETE /api/logbuch/[id]:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/api/logbuch/route.ts b/app/api/logbuch/route.ts new file mode 100644 index 0000000..6817f61 --- /dev/null +++ b/app/api/logbuch/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; +import { getSession } from '@/lib/session'; +import type { SelectedObjekt } from '@/types/logbuch'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const kuppel = searchParams.get('kuppel') || 'West'; + const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); + + try { + const rows = await query( + `SELECT + l.ID, l.Kuppel, l.ArtFuehrung, + DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn, + DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende, + l.Besucher, l.Bemerkungen, + l.WetterTemp, l.WetterFeuchte, l.WetterDruck, + l.created_by, l.created_at, + GROUP_CONCAT(DISTINCT b.Kuerzel ORDER BY b.Kuerzel SEPARATOR ', ') AS BEOs, + GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte + FROM logbuch l + LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID + LEFT JOIN beos b ON b.ID = lb.BeoID + LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID + WHERE l.Kuppel = ? + GROUP BY l.ID + ORDER BY l.Beginn DESC + LIMIT ?`, + [kuppel, limit] + ); + return NextResponse.json(rows); + } catch (error) { + console.error('GET /api/logbuch:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + const body = await request.json(); + const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; + + const [result] = await (await import('@/lib/db')).getPool().execute( + `INSERT INTO logbuch (Kuppel, ArtFuehrung, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + Kuppel, ArtFuehrung, Beginn, Ende, + Besucher ?? 0, + Bemerkungen?.slice(0, 500) || null, + Wetter?.temp ?? null, + Wetter?.feuchte ?? null, + Wetter?.druck ?? null, + session.beoId, + ] + ) as [{ insertId: number }, unknown]; + + const logbuchId = result.insertId; + + for (const beoId of (beoIds as number[]) || []) { + await query('INSERT INTO logbuch_beos (LogbuchID, BeoID) VALUES (?, ?)', [logbuchId, beoId]); + } + + for (const obj of (objekte as SelectedObjekt[]) || []) { + let objektId = obj.ID; + if (!objektId) { + const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; + if (existing[0]) { + objektId = existing[0].ID; + } else { + const [ins] = await (await import('@/lib/db')).getPool().execute( + 'INSERT INTO objekte (Name) VALUES (?)', [obj.Name] + ) as [{ insertId: number }, unknown]; + objektId = ins.insertId; + } + } + await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); + await query( + 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', + [logbuchId, objektId, obj.Name] + ); + } + + return NextResponse.json({ id: logbuchId }, { status: 201 }); + } catch (error) { + console.error('POST /api/logbuch:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..302e826 --- /dev/null +++ b/app/api/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { deleteSession } from '@/lib/session'; + +export async function POST() { + await deleteSession(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/objekte/route.ts b/app/api/objekte/route.ts new file mode 100644 index 0000000..1b02bd5 --- /dev/null +++ b/app/api/objekte/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET() { + try { + const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100'); + return NextResponse.json(rows); + } catch (error) { + console.error('GET /api/objekte:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/api/wetter/route.ts b/app/api/wetter/route.ts new file mode 100644 index 0000000..2f67013 --- /dev/null +++ b/app/api/wetter/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + const temp = Math.round((8 + Math.random() * 15) * 10) / 10; + const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10; + const druck = Math.round((990 + Math.random() * 30) * 10) / 10; + return NextResponse.json({ temp, feuchte, druck }); +} diff --git a/app/change-password/actions.ts b/app/change-password/actions.ts new file mode 100644 index 0000000..a598025 --- /dev/null +++ b/app/change-password/actions.ts @@ -0,0 +1,41 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { getSession, createSession } from '@/lib/session'; +import { hashPassword } from '@/lib/auth'; +import { query } from '@/lib/db'; + +export async function changePassword( + _prevState: { error: string } | undefined, + formData: FormData +): Promise<{ error: string }> { + const session = await getSession(); + if (!session) redirect('/login'); + + const newPassword = formData.get('newPassword') as string; + const confirmPassword = formData.get('confirmPassword') as string; + + if (!newPassword || newPassword.length < 6) { + return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' }; + } + + if (newPassword !== confirmPassword) { + return { error: 'Die Passwörter stimmen nicht überein.' }; + } + + const hashed = await hashPassword(newPassword); + await query( + 'UPDATE beos SET Passwort = ?, MustChangePassword = 0 WHERE ID = ?', + [hashed, session.beoId] + ); + + await createSession({ + kuerzel: session.kuerzel, + beoId: session.beoId, + beoName: session.beoName, + mustChangePassword: false, + isAuthenticated: true, + }); + + redirect('/'); +} diff --git a/app/change-password/page.tsx b/app/change-password/page.tsx new file mode 100644 index 0000000..c080acf --- /dev/null +++ b/app/change-password/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useActionState, useState } from 'react'; +import { changePassword } from './actions'; + +export default function ChangePasswordPage() { + const [state, action, isPending] = useActionState(changePassword, undefined); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + return ( +
+
+

Logbuch — Sternwarte Welzheim

+ +
+
+

Passwort ändern

+

+ Bitte wählen Sie ein neues Passwort, bevor Sie fortfahren. +

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + {state?.error && ( +
+ {state.error} +
+ )} + + +
+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..0ef1fc0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,34 +1,15 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata } from 'next'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Logbuch — Sternwarte Welzheim', + description: 'Logbuch für die Sternwarte Welzheim', }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} - + + {children} ); } diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..2d126be --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,37 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { verifyCredentials } from '@/lib/auth'; +import { createSession } from '@/lib/session'; + +export async function login( + _prevState: { error: string } | undefined, + formData: FormData +): Promise<{ error: string }> { + const kuerzel = (formData.get('username') as string)?.trim(); + const password = formData.get('password') as string; + + if (!kuerzel || !password) { + return { error: 'Bitte Kürzel und Passwort eingeben.' }; + } + + const result = await verifyCredentials(kuerzel, password); + + if (!result || !result.valid) { + return { error: 'Ungültiges Kürzel oder Passwort.' }; + } + + await createSession({ + kuerzel: result.beo.Kuerzel, + beoId: result.beo.ID, + beoName: result.beo.Name, + mustChangePassword: result.beo.MustChangePassword === 1, + isAuthenticated: true, + }); + + if (result.beo.MustChangePassword === 1) { + redirect('/change-password'); + } + + redirect('/'); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..8de1469 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useActionState, useState } from 'react'; +import { login } from './actions'; +import packageJson from '@/package.json'; + +export default function LoginPage() { + const [state, loginAction, isPending] = useActionState(login, undefined); + const [showPassword, setShowPassword] = useState(false); + + const version = packageJson.version; + const buildDate = + process.env.NEXT_PUBLIC_BUILD_DATE || + new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + return ( +
+
+
+

Logbuch — Sternwarte Welzheim

+
+ +
+
+

Anmeldung

+ +
+
+ + +
+ +
+ +
+ + +
+
+ + {state?.error && ( +
+ {state.error} +
+ )} + + +
+
+
+ + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..376c387 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,16 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation'; +import { getSession } from '@/lib/session'; +import MainClient from './MainClient'; + +export default async function HomePage() { + const session = await getSession(); + if (!session) redirect('/login'); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
+ ); } diff --git a/claude/Logbuch.md b/claude/Logbuch.md new file mode 100644 index 0000000..83bf7ad --- /dev/null +++ b/claude/Logbuch.md @@ -0,0 +1,42 @@ +Erstelle ein Logbuch für die Sternwarte in Welzheim. ++ Die Sternwarte hat 4 Kuppeln: West, Ost, Süd und Pluto, die West-Kuppel ist der Default. Für jede Kuppel gibt es ein eigenes Logbuch. Alle 4 Logbücher sind identisch gebaut. ++ Als Datenbank soll eine MYSQL verwendet werden ++ Als Sprache soll nextjs zum Einsatz kommen ++ Der Zugang zu der Webseite soll per User/Passwort gesichert werden ++ Der User soll folgende Einträge eingeben können + + Kuppel (West, Ost ...) + + Art der Führung. Auswahl aus folgenden Möglichkeiten: + + Reguläre Führung + + Sonderführung + + BEO-Sitzung + + Sonnenführung + + Technischer Dienst + + Beobachtung + + Tag der offenen Tür + + Sonstiges + Reguläre Führung ist der Default + + Beginn der Führung (Datum und Uhrzeit, Datum ist vorausgefüllt mit dem aktuellen Datum) + + Ende der Führung (wie Beginn) + + Anzahl der Besucher + + BEOs Hier ist der angemeldet User als 1. BEO vorbelegt, weitere können zugefügt werden + + diese BEOs werden aus einer Tabelle in der MYSQL-DB gelesen + + beobachtete Objekte + + schon gespeicherte Objekte werden ebenfalls aus einer Tabelle in der MYSQL gelesen und zur Auswahl angeboten, eine Neueingabe ist natürlich möglich + + ein Feld mit Bemerkungen; freier Text mit max. 500 Zeichen + + die aktuellen Wetterdaten (werden aus aus dem Wetterarchiv ausgelesen, kann vorerst via MOCK simuliert werden) ++ Zusätzlich zur Eingabe kann die Liste der letzten Einträge (20 pro Seite) angezeigt werden als Tabelle ++ Umschaltung zwischen Eingabe und Liste über TABs + +Besonderheit bei der Eingabe der beobachteten Objekte: ++ die Auswahlliste soll als ersten Eintrag 'Neu' haben ++ dann folgen die zuletzt eingetragenen Objekte in zeitlich absteigender Reihenfolge + +User/Passwort-Eingabe ++ jeder User, der sich anmeldet, MUSS in der BEO-Tabelle in der DB vorhanden sein, verglichen wird sein Namenskürzel (das steht in der DB) ++ beim ersten mal meldet sich der User mit einem Default-Passwort an und wird aufgefordert, diese sofort zu ändern ++ falls er es nicht ändert, kann er nicht weiter machen ++ Das selbst gewählte Passwort wird dann in der BEO-Tabelle gespeichert und beim nächsten Login verwendet + +Bitte stelle Rückfragen, falls etwas unklar ist. + + diff --git a/components/BeoSelector.tsx b/components/BeoSelector.tsx new file mode 100644 index 0000000..b405c2e --- /dev/null +++ b/components/BeoSelector.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { BeoOption } from '@/types/logbuch'; + +interface Props { + selected: BeoOption[]; + onChange: (beos: BeoOption[]) => void; +} + +export default function BeoSelector({ selected, onChange }: Props) { + const [all, setAll] = useState([]); + + useEffect(() => { + fetch('/api/beos') + .then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); }) + .then(setAll) + .catch(() => {}); + }, []); + + const selectedIds = new Set(selected.map((b) => b.ID)); + const available = all.filter((b) => !selectedIds.has(b.ID)); + + function add(id: string) { + if (!id) return; + const beo = all.find((b) => b.ID === parseInt(id)); + if (beo) onChange([...selected, beo]); + } + + function remove(id: number) { + onChange(selected.filter((b) => b.ID !== id)); + } + + return ( +
+
+ {selected.map((b) => ( + + {b.Kuerzel} — {b.Name} + + + ))} +
+ {available.length > 0 && ( + + )} +
+ ); +} diff --git a/components/LogbuchForm.tsx b/components/LogbuchForm.tsx new file mode 100644 index 0000000..c2ca6f4 --- /dev/null +++ b/components/LogbuchForm.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch'; +import { ARTEN } from '@/types/logbuch'; +import BeoSelector from './BeoSelector'; +import ObjektSelector from './ObjektSelector'; + +interface Props { + kuppel: Kuppel; + currentUserBeo: BeoOption; + editEntry?: LogbuchEintrag | null; + onSaved: () => void; +} + +function toLocalDatetimeValue(isoOrDatetime: string): string { + if (!isoOrDatetime) return ''; + return isoOrDatetime.slice(0, 16); +} + +function nowLocalDatetime(): string { + const now = new Date(); + 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())}`; +} + +export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) { + const [artFuehrung, setArtFuehrung] = useState('Reguläre Führung'); + const [beginn, setBeginn] = useState(nowLocalDatetime()); + const [ende, setEnde] = useState(nowLocalDatetime()); + const [besucher, setBesucher] = useState(0); + const [beos, setBeos] = useState([currentUserBeo]); + const [objekte, setObjekte] = useState([]); + const [bemerkungen, setBemerkungen] = useState(''); + const [wetter, setWetter] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + useEffect(() => { + fetch('/api/wetter') + .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) + .then(setWetter) + .catch(() => {}); + }, []); + + useEffect(() => { + if (editEntry) { + setArtFuehrung(editEntry.ArtFuehrung); + setBeginn(toLocalDatetimeValue(editEntry.Beginn)); + setEnde(toLocalDatetimeValue(editEntry.Ende)); + setBesucher(editEntry.Besucher); + setBemerkungen(editEntry.Bemerkungen ?? ''); + if (editEntry.WetterTemp !== null) { + setWetter({ + temp: editEntry.WetterTemp ?? 0, + feuchte: editEntry.WetterFeuchte ?? 0, + druck: editEntry.WetterDruck ?? 0, + }); + } + } else { + setArtFuehrung('Reguläre Führung'); + setBeginn(nowLocalDatetime()); + setEnde(nowLocalDatetime()); + setBesucher(0); + setBeos([currentUserBeo]); + setObjekte([]); + setBemerkungen(''); + } + }, [editEntry, currentUserBeo]); + + useEffect(() => { + if (editEntry && editEntry.BEOs) { + fetch('/api/beos') + .then((r) => r.json()) + .then((all: BeoOption[]) => { + const kuerzel = editEntry.BEOs.split(', ').map((k) => k.trim()); + setBeos(all.filter((b) => kuerzel.includes(b.Kuerzel))); + }) + .catch(() => {}); + } + if (editEntry && editEntry.Objekte) { + const names = editEntry.Objekte.split(', ').map((n) => n.trim()); + fetch('/api/objekte') + .then((r) => r.json()) + .then((all: { ID: number; Name: string }[]) => { + const result: SelectedObjekt[] = names.map((name) => { + const found = all.find((o) => o.Name === name); + return { ID: found?.ID ?? null, Name: name }; + }); + setObjekte(result); + }) + .catch(() => {}); + } + }, [editEntry]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(''); + setSuccess(false); + + const body = { + Kuppel: kuppel, + ArtFuehrung: artFuehrung, + Beginn: beginn, + Ende: ende, + Besucher: besucher, + beoIds: beos.map((b) => b.ID), + objekte, + Bemerkungen: bemerkungen, + Wetter: wetter, + }; + + const url = editEntry ? `/api/logbuch/${editEntry.ID}` : '/api/logbuch'; + const method = editEntry ? 'PUT' : 'POST'; + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + setSuccess(true); + if (!editEntry) { + setBeginn(nowLocalDatetime()); + setEnde(nowLocalDatetime()); + setBesucher(0); + setBeos([currentUserBeo]); + setObjekte([]); + setBemerkungen(''); + } + onSaved(); + } catch { + setError('Fehler beim Speichern. Bitte erneut versuchen.'); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + setBeginn(e.target.value)} + required + className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" + /> +
+
+ + setEnde(e.target.value)} + required + className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" + /> +
+
+ +
+ + setBesucher(parseInt(e.target.value) || 0)} + min={0} + max={9999} + className="w-32 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +