Compare commits

...

15 Commits

Author SHA1 Message Date
admin 0ea960259c v1.6.0: Admin-Passwort-Reset, Login per Nachname, Default-PW-Sperre 2026-05-11 12:20:44 +02:00
admin 4d84b8f718 Image wird auch als 'latest' getagged 2026-05-10 15:52:58 +02:00
admin 419ad39ead bump version to 1.5.1; make logout button red 2026-05-10 15:40:04 +02:00
admin 1a85f0ae36 wetter: fetch real data from weather station API 2026-05-10 15:32:13 +02:00
admin 1451f45711 add Statistik component and API route 2026-05-10 14:55:27 +02:00
admin 58a4aeb984 bump version to 1.5.0 2026-05-10 14:53:29 +02:00
admin c04935a117 v1.4.0: Monatsfilter, Pagination, Rollenverwaltung, DB-Bereinigung
- Liste: Monatsfilter mit ←/→ Navigation, Standard = aktueller Monat
- Liste: Pagination (10 Einträge/Seite)
- BEO-Auswahl filtert nur role='guide'
- logbuch_objekte: ObjektName entfernt, JOIN auf objekte
- utf8mb4 Migration und DB-Charset-Umstellung
- SSH-Tunnel-Support: MySQL auf 127.0.0.1:3336
- phpMyAdmin unter /myadmin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 10:39:11 +02:00
admin 4f1ebb8aa6 Vorherige Änderungen an Formular und Typen nachcommittet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 09:37:11 +02:00
admin 3fc5c9ff7a v1.5.0: utf8mb4-Migration, Rollen, phpMyAdmin, DB-Bereinigung
- Datenbank auf utf8mb4_unicode_ci migriert (migrate_to_utf8mb4.sh)
- beos: Spalte 'role' (kommagetrennte Rollen: guide, admin, key, deleted)
- BEO-Auswahl im Formular filtert nur noch role='guide'
- logbuch_objekte: ObjektName-Spalte entfernt, stattdessen JOIN auf objekte
- lib/db.ts: charset utf8mb4 in Connection-Pool
- Session und Auth um role-Feld erweitert
- compose.yml: phpMyAdmin mit Traefik unter /myadmin
- compose.yml: MySQL auf 127.0.0.1:3336 für SSH-Tunnel (lokale Entwicklung)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 09:34:38 +02:00
admin 8bff795247 v1.4.0: Sonderführung, Zeiterfassung, Enter-Navigation, Objektsuche
- Sonderführung: neues Feld 'Name/Gruppe' (DB-Spalte SonderName), in Liste sichtbar
- Wetter: Race-Condition behoben (API überschreibt DB-Werte beim Bearbeiten nicht mehr)
- Zeiterfassung: TimePicker5 ersetzt durch freie Texteingabe (TimeInput) mit Validierung
- Enter-Taste: navigiert zum nächsten Feld statt die Form abzuschicken; Luftdruck → zurück zu Art; Bemerkungen bleibt normal
- Objektsuche: Freitext-Suche im ObjektSelector, filtert nach Präfix (case-insensitive)
- UI-Anpassungen: kompakteres Layout (space-y-2, kleinere Abstände)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:50:51 +02:00
admin 743bebca2d v1.3.0: Wetterdaten in Liste, Zeit als eine Spalte
- Vollständige Liste: Start- und Endzeit in einer Spalte untereinander
- Vollständige Liste: neue Wetter-Spalte (Temp/Feuchte/Druck) ganz rechts
- Kompakte Vorschau bleibt unverändert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 09:07:57 +02:00
admin 1291f0d027 v1.2.1: Wetter als editierbare Eingabefelder
- 3 editierbare Felder (Temp, Feuchte, Luftdruck) statt read-only Anzeige
- Vorausgefüllt mit aktuellen API-Werten
- Feuchte und Luftdruck als Ganzzahl (kein Dezimal)
- Felder gleichmäßig verteilt (links/Mitte/rechts, je 1/3 Breite)
- Wetter-Überschrift entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 08:54:41 +02:00
admin 07d2a13014 v1.2.0: Formular-Redesign und Listen-Verbesserungen
- Eingabe: alle 5 Felder (Führung, Datum, Start, Ende, Besucher) in einer Zeile
- Eingabe: Datum einmalig, Start- und Endzeit getrennt
- Führungsarten: Kürzel werden nur in DB gespeichert, Anzeige als Klartext
- Liste: Datum und Zeit getrennt in eigenen Spalten
- Hintergrundfarbe #EEF4FF auf Login- und Passwort-Seite übertragen
- Alle Inputfelder gleich hoch (text-sm durchgehend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 11:00:19 +02:00
admin 0e3c5ca835 v1.1.0: Druck-Optimierung, UI-Verbesserungen
- Drucklayout: kein innerer Rahmen, volle DIN-A4-Breite, @page-Regel
- Besucher-Spalte: zeigt keine 0 an wenn kein Wert eingetragen
- Beginn-Zeit synchronisiert automatisch die Ende-Zeit
- Hintergrundfarbe auf helles Blau (#EEF4FF)
- Listen-Tab und kompakte Vorschau mit weißem Rahmen
- Besucher-Spalte schmaler, Header als "Bes." abgekürzt
- Drucken-Button im Listen-Tab, Navigation bei Druck ausgeblendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:25:11 +02:00
admin 30c734220a Fix iOS text color, viewport meta tag, and security improvements
- Add viewport meta tag to prevent iOS zoom/scaling issues
- Fix text color on iOS Safari (explicit text-gray-900 on buttons, inputs, TimePicker5)
- Add session checks to /api/beos, /api/objekte, /api/wetter
- Revert iframe embedding (X-Frame-Options: DENY, SameSite: lax)
- docker-compose.prod.yml: fix DB_PORT=3306 for production
- Add docker-compose.prod.yml, .env.prod.example, dump/import scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 17:38:08 +02:00
37 changed files with 1570 additions and 262 deletions
+10
View File
@@ -0,0 +1,10 @@
### Zugriff zur Datenbank:
Die Remote-Datenbank auf logbuch.fuerst-.stuttgart.de wird zum lokalen entwickeln über einen SSH-Tunner erreicht:
~~~
ssh -L 3336:localhost:3336 rxf@logbuch.fuerst-stuttgart.de -N
~~~
Dieser ist vor dem Starten des Programme einmal einzurichten!!
+2 -2
View File
@@ -16,9 +16,9 @@ No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Do
Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`. Next.js 16 App Router application. All pages are server components; interactive parts are Client Components in `app/MainClient.tsx` and `components/`.
**Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `logbuch123` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement. **Auth flow**: Users come from the existing MySQL `beos` table (not a separate users table). Login via `app/login/actions.ts``lib/auth.ts` (bcryptjs). Sessions are JWT cookies via jose (`lib/session.ts`, 1-hour expiry). If `pw IS NULL`, the default password is `welzheim` and `mustChangePassword` is forced to `true`. The middleware file exports `proxy` (not `middleware`) — Next.js 16 requirement.
**Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **latin1** — avoid non-ASCII characters in SQL WHERE clauses; use `LIKE 'Ascii%'` prefix patterns instead. **Database**: MySQL, database name `sternwarte`, via `lib/db.ts` connection pool. The pre-existing `beos` table has non-standard columns: `` `kürzel` `` (umlaut → always needs backticks), `pw`, `id` (all lowercase). The DB charset is **utf8mb4** (collation `utf8mb4_unicode_ci`); connection pool uses `charset: 'utf8mb4'`.
**SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``. **SQL in JS**: MySQL backticks inside JS template literals cause parse errors. Write complex queries using string concatenation (`+`), not template literals. `LIMIT` cannot be a parameterized placeholder in complex grouped queries — embed it directly after validating: `LIST_SQL + \` LIMIT ${limit}\``.
+76 -33
View File
@@ -5,17 +5,19 @@ import { KUPPELN } from '@/types/logbuch';
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
import LogbuchForm from '@/components/LogbuchForm'; import LogbuchForm from '@/components/LogbuchForm';
import LogbuchList from '@/components/LogbuchList'; import LogbuchList from '@/components/LogbuchList';
import Statistik from '@/components/Statistik';
import packageJson from '@/package.json'; import packageJson from '@/package.json';
interface Props { interface Props {
kuerzel: string; kuerzel: string;
beoId: number; beoId: number;
beoName: string; beoName: string;
role: string | null;
} }
export default function MainClient({ kuerzel, beoId, beoName }: Props) { export default function MainClient({ kuerzel, beoId, beoName, role }: Props) {
const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West'); const [activeKuppel, setActiveKuppel] = useState<Kuppel>('West');
const [activeTab, setActiveTab] = useState<'eingabe' | 'liste'>('eingabe'); const [activeTab, setActiveTab] = useState<'eingabe' | 'liste' | 'statistik'>('eingabe');
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null); const [editEntry, setEditEntry] = useState<LogbuchEintrag | null>(null);
@@ -43,19 +45,27 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
} }
return ( return (
<div className="min-h-screen bg-white py-2 px-2 sm:py-4 sm:px-4"> <div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-6 bg-[#FFFFDD]"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF4FF] print:max-w-none print:border-0 print:p-0 print:bg-white">
{/* Header */} {/* Header */}
<div className="flex justify-between items-start sm:items-center mb-4 gap-2"> <div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden">
<h1 className="text-xl sm:text-2xl font-bold leading-tight"> <h1 className="text-xl sm:text-2xl font-bold leading-tight">
<span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span> <span className="hidden sm:inline">Sternwarte-Welzheim &nbsp; </span>
Logbuch für {activeKuppel}-Kuppel Logbuch für {activeKuppel}-Kuppel
</h1> </h1>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{role?.includes('admin') && (
<a
href="/admin"
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
>
Admin
</a>
)}
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-gray-200 hover:bg-gray-300 rounded-lg text-gray-700" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded-lg text-white"
> >
Abmelden Abmelden
</button> </button>
@@ -63,7 +73,7 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
</div> </div>
{/* Kuppel-Tabs */} {/* Kuppel-Tabs */}
<div className="flex gap-1 mb-4 border-b-2 border-gray-300 overflow-x-auto"> <div className="flex gap-1 mb-3 border-b-2 border-gray-300 overflow-x-auto print:hidden">
{KUPPELN.map((k) => ( {KUPPELN.map((k) => (
<button <button
key={k} key={k}
@@ -80,9 +90,9 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
))} ))}
</div> </div>
{/* Eingabe/Liste-Tabs */} {/* Eingabe/Liste/Statistik-Tabs */}
<div className="flex gap-1 mb-4 border-b border-gray-200"> <div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden">
{(['eingabe', 'liste'] as const).map((tab) => ( {(['eingabe', 'liste', 'statistik'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }} onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }}
@@ -92,7 +102,7 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
{tab === 'eingabe' ? 'Eingabe' : 'Liste'} {tab === 'eingabe' ? 'Eingabe' : tab === 'liste' ? 'Liste' : 'Statistik'}
</button> </button>
))} ))}
</div> </div>
@@ -115,37 +125,70 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
{/* Kompakte Liste — nur auf Desktop sichtbar */} {/* Kompakte Liste — nur auf Desktop sichtbar */}
<div className="hidden lg:block mt-5 border-t-2 border-gray-300 pt-4"> <div className="hidden lg:block mt-5 border-t-2 border-gray-300 pt-4">
<div className="flex justify-between items-center mb-2"> <div className="border-2 border-gray-400 rounded-xl bg-white p-3">
<h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2> <div className="flex justify-between items-center mb-2">
<button <h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2>
onClick={() => setActiveTab('liste')} <button
className="text-xs text-blue-600 hover:underline" onClick={() => setActiveTab('liste')}
> className="text-xs text-blue-600 hover:underline"
Alle anzeigen >
</button> Alle anzeigen
</button>
</div>
<LogbuchList
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
limit={5}
compact
/>
</div> </div>
<LogbuchList
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
limit={5}
compact
/>
</div> </div>
</> </>
)} )}
{/* Liste-Tab: vollständige Liste */} {/* Liste-Tab: vollständige Liste */}
{activeTab === 'liste' && ( {activeTab === 'liste' && (
<LogbuchList <div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
kuppel={activeKuppel} <div className="flex justify-between items-center mb-2 print:hidden">
refreshKey={refreshKey} <span className="text-sm font-semibold text-gray-600">Einträge {activeKuppel}-Kuppel</span>
onEdit={handleEdit} <button
limit={20} onClick={() => window.print()}
/> className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
</div>
<div className="hidden print:block mb-4">
<div className="text-lg font-bold">Sternwarte Welzheim Logbuch {activeKuppel}-Kuppel</div>
<div className="text-sm text-gray-500">Ausdruck vom {new Date().toLocaleDateString('de-DE')}</div>
</div>
<LogbuchList
kuppel={activeKuppel}
refreshKey={refreshKey}
onEdit={handleEdit}
limit={15}
/>
</div>
)} )}
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4"> {/* Statistik-Tab */}
{activeTab === 'statistik' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<div className="flex justify-between items-center mb-2 print:hidden">
<span className="text-sm font-semibold text-gray-600">Statistik {activeKuppel}-Kuppel</span>
<button
onClick={() => window.print()}
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
>
🖨 Drucken
</button>
</div>
<Statistik kuppel={activeKuppel} />
</div>
)}
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
<div> <div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline"> <a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
rxf@gmx.de rxf@gmx.de
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { useActionState } from 'react';
import { resetPassword } from './actions';
interface Props {
userId: number;
userName: string;
}
export default function ResetButton({ userId, userName }: Props) {
const [state, action, isPending] = useActionState(resetPassword, undefined);
return (
<div>
<form
action={action}
onSubmit={(e) => {
if (!confirm(`Passwort von „${userName}" wirklich zurücksetzen?`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="id" value={userId} />
<button
type="submit"
disabled={isPending}
className="px-3 py-1 text-xs font-medium bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200 disabled:opacity-50"
>
{isPending ? 'Bitte warten…' : 'Zurücksetzen'}
</button>
</form>
{state?.success && (
<p className="text-xs text-green-700 mt-1 max-w-xs">{state.success}</p>
)}
{state?.error && (
<p className="text-xs text-red-600 mt-1">{state.error}</p>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use server';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { query } from '@/lib/db';
export interface BeoUser {
id: number;
kürzel: string | null;
name: string;
vorname: string | null;
pw: string | null;
role: string | null;
}
export async function listUsers(): Promise<BeoUser[]> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) redirect('/');
const rows = await query(
'SELECT id, `kürzel`, name, vorname, pw, role FROM beos ORDER BY name, vorname',
[]
) as BeoUser[];
return rows;
}
export async function resetPassword(
_prevState: { error?: string; success?: string } | undefined,
formData: FormData
): Promise<{ error?: string; success?: string }> {
const session = await getSession();
if (!session || !session.role?.includes('admin')) {
return { error: 'Keine Berechtigung.' };
}
const idRaw = formData.get('id');
const id = Number(idRaw);
if (!id || isNaN(id)) {
return { error: 'Ungültige Benutzer-ID.' };
}
await query(
'UPDATE beos SET pw = NULL, MustChangePassword = 1 WHERE id = ?',
[id]
);
return { success: 'Passwort wurde zurückgesetzt. Der Benutzer muss sich mit dem Standard-Passwort anmelden und es dann ändern.' };
}
+65
View File
@@ -0,0 +1,65 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { listUsers } from './actions';
import ResetButton from './ResetButton';
export default async function AdminPage() {
const session = await getSession();
if (!session) redirect('/login');
if (session.role === null || !session.role.includes('admin')) redirect('/');
const users = await listUsers();
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">
<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.pw == null ? (
<span className="text-amber-600 font-medium">Standard</span>
) : (
<span className="text-green-700">gesetzt</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>
</main>
</div>
);
}
+4 -1
View File
@@ -1,10 +1,13 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
export async function GET() { export async function GET() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { try {
const rows = await query( const rows = await query(
'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL ORDER BY name ASC' 'SELECT id AS ID, `kürzel` AS Kuerzel, CONCAT(IFNULL(vorname, \'\'), IF(vorname IS NOT NULL, \' \', \'\'), name) AS Name FROM beos WHERE `kürzel` IS NOT NULL AND FIND_IN_SET(\'guide\', role) > 0 ORDER BY name ASC'
) as { ID: number; Kuerzel: string; Name: string }[]; ) as { ID: number; Kuerzel: string; Name: string }[];
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
+7 -8
View File
@@ -12,14 +12,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
try { try {
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
await getPool().execute( await getPool().execute(
`UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, Beginn=?, Ende=?, Besucher=?, 'UPDATE logbuch SET Kuppel=?, ArtFuehrung=?, SonderName=?, Beginn=?, Ende=?, Besucher=?,' +
Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? ' Bemerkungen=?, WetterTemp=?, WetterFeuchte=?, WetterDruck=? WHERE ID=?',
WHERE ID=?`,
[ [
Kuppel, ArtFuehrung, Beginn, Ende, Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende,
Besucher ?? 0, Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null, Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null, Wetter?.temp ?? null,
@@ -39,7 +38,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
for (const obj of (objekte as SelectedObjekt[]) || []) { for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID; let objektId = obj.ID;
if (!objektId) { if (!objektId) {
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) { if (existing[0]) {
objektId = existing[0].ID; objektId = existing[0].ID;
} else { } else {
@@ -51,8 +50,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
} }
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query( await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId, obj.Name] [logbuchId, objektId]
); );
} }
+32 -12
View File
@@ -8,15 +8,16 @@ const LIST_SQL =
' l.ID, l.Kuppel, l.ArtFuehrung,' + ' l.ID, l.Kuppel, l.ArtFuehrung,' +
" DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn," + " DATE_FORMAT(l.Beginn, '%Y-%m-%dT%H:%i') AS Beginn," +
" DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende," + " DATE_FORMAT(l.Ende, '%Y-%m-%dT%H:%i') AS Ende," +
' l.Besucher, l.Bemerkungen,' + ' l.Besucher, l.Bemerkungen, l.SonderName,' +
' l.WetterTemp, l.WetterFeuchte, l.WetterDruck,' + ' l.WetterTemp, l.WetterFeuchte, l.WetterDruck,' +
' l.created_by, l.created_at,' + ' l.created_by, l.created_at,' +
" GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," + " GROUP_CONCAT(DISTINCT bk.kuerzel ORDER BY bk.kuerzel SEPARATOR ', ') AS BEOs," +
" GROUP_CONCAT(DISTINCT lo.ObjektName ORDER BY lo.ObjektName SEPARATOR ', ') AS Objekte" + " GROUP_CONCAT(DISTINCT o.Name ORDER BY o.Name SEPARATOR ', ') AS Objekte" +
' FROM logbuch l' + ' FROM logbuch l' +
' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' + ' LEFT JOIN logbuch_beos lb ON lb.LogbuchID = l.ID' +
' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' + ' LEFT JOIN (SELECT id, `kürzel` AS kuerzel FROM beos) bk ON bk.id = lb.BeoID' +
' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' + ' LEFT JOIN logbuch_objekte lo ON lo.LogbuchID = l.ID' +
' LEFT JOIN objekte o ON o.ID = lo.ObjektID' +
' WHERE l.Kuppel = ?' + ' WHERE l.Kuppel = ?' +
' GROUP BY l.ID' + ' GROUP BY l.ID' +
' ORDER BY l.Beginn DESC'; ' ORDER BY l.Beginn DESC';
@@ -24,11 +25,30 @@ const LIST_SQL =
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West'; const kuppel = searchParams.get('kuppel') || 'West';
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100);
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
const month = searchParams.get('month') || '';
let listWhere = 'WHERE l.Kuppel = ?';
let countWhere = 'WHERE Kuppel = ?';
let params: (string | number | null)[] = [kuppel];
if (month && /^\d{4}-\d{2}$/.test(month)) {
const [y, m] = month.split('-').map(Number);
const start = `${y}-${String(m).padStart(2, '0')}-01`;
const nextM = m === 12 ? 1 : m + 1;
const nextY = m === 12 ? y + 1 : y;
const end = `${nextY}-${String(nextM).padStart(2, '0')}-01`;
listWhere += ' AND l.Beginn >= ? AND l.Beginn < ?';
countWhere += ' AND Beginn >= ? AND Beginn < ?';
params = [kuppel, start, end];
}
try { try {
const rows = await query(LIST_SQL + ` LIMIT ${limit}`, [kuppel]); const [countRows, entries] = await Promise.all([
return NextResponse.json(rows); query('SELECT COUNT(*) AS total FROM logbuch ' + countWhere, params) as Promise<{ total: number }[]>,
query(LIST_SQL.replace('WHERE l.Kuppel = ?', listWhere) + ` LIMIT ${limit} OFFSET ${offset}`, params),
]);
return NextResponse.json({ entries, total: (countRows as unknown as { total: number }[])[0]?.total ?? 0 });
} catch (error) { } catch (error) {
console.error('GET /api/logbuch:', error); console.error('GET /api/logbuch:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
@@ -41,14 +61,14 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { Kuppel, ArtFuehrung, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body; const { Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, beoIds, objekte, Bemerkungen, Wetter } = body;
const pool = getPool(); const pool = getPool();
const [result] = await pool.execute( const [result] = await pool.execute(
'INSERT INTO logbuch (Kuppel, ArtFuehrung, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' + 'INSERT INTO logbuch (Kuppel, ArtFuehrung, SonderName, Beginn, Ende, Besucher, Bemerkungen, WetterTemp, WetterFeuchte, WetterDruck, created_by)' +
' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Kuppel, ArtFuehrung, Beginn, Ende, Kuppel, ArtFuehrung, SonderName || null, Beginn, Ende,
Besucher ?? 0, Besucher ?? 0,
Bemerkungen?.slice(0, 500) || null, Bemerkungen?.slice(0, 500) || null,
Wetter?.temp ?? null, Wetter?.temp ?? null,
@@ -67,7 +87,7 @@ export async function POST(request: NextRequest) {
for (const obj of (objekte as SelectedObjekt[]) || []) { for (const obj of (objekte as SelectedObjekt[]) || []) {
let objektId = obj.ID; let objektId = obj.ID;
if (!objektId) { if (!objektId) {
const existing = await query('SELECT ID FROM objekte WHERE Name = ?', [obj.Name]) as { ID: number }[]; const existing = await query('SELECT ID, Name FROM objekte WHERE LOWER(Name) = LOWER(?)', [obj.Name]) as { ID: number; Name: string }[];
if (existing[0]) { if (existing[0]) {
objektId = existing[0].ID; objektId = existing[0].ID;
} else { } else {
@@ -77,8 +97,8 @@ export async function POST(request: NextRequest) {
} }
await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]); await query('UPDATE objekte SET LastUsed = NOW() WHERE ID = ?', [objektId]);
await query( await query(
'INSERT INTO logbuch_objekte (LogbuchID, ObjektID, ObjektName) VALUES (?, ?, ?)', 'INSERT INTO logbuch_objekte (LogbuchID, ObjektID) VALUES (?, ?)',
[logbuchId, objektId, obj.Name] [logbuchId, objektId]
); );
} }
+3
View File
@@ -1,7 +1,10 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { query } from '@/lib/db'; import { query } from '@/lib/db';
import { getSession } from '@/lib/session';
export async function GET() { export async function GET() {
const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
try { try {
const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100'); const rows = await query('SELECT ID, Name FROM objekte ORDER BY LastUsed DESC LIMIT 100');
return NextResponse.json(rows); return NextResponse.json(rows);
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const kuppel = searchParams.get('kuppel') || 'West';
const year = parseInt(searchParams.get('year') || String(new Date().getFullYear()), 10);
try {
// 1) Monatliche Besucherzahlen nach ArtFuehrung
const monthlyRows = await query(
`SELECT
MONTH(Beginn) AS monat,
ArtFuehrung,
SUM(Besucher) AS besucher,
COUNT(*) AS anzahl
FROM logbuch
WHERE Kuppel = ? AND YEAR(Beginn) = ?
GROUP BY MONTH(Beginn), ArtFuehrung
ORDER BY monat, ArtFuehrung`,
[kuppel, year]
) as { monat: number; ArtFuehrung: string; besucher: number; anzahl: number }[];
// 2) Kumulierte Besucher im Jahr
const cumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { total: number | null }[];
// 3) Anzahl Führungstage (distinct Datum)
const tageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE Kuppel = ? AND YEAR(Beginn) = ?`,
[kuppel, year]
) as { tage: number }[];
// 4) Kumulierte Besucher über alle Kuppeln
const allCumulativeRows = await query(
`SELECT SUM(Besucher) AS total FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { total: number | null }[];
// 5) Führungstage über alle Kuppeln
const allTageRows = await query(
`SELECT COUNT(DISTINCT DATE(Beginn)) AS tage FROM logbuch WHERE YEAR(Beginn) = ?`,
[year]
) as { tage: number }[];
return NextResponse.json({
monthly: monthlyRows.map((r) => ({
monat: Number(r.monat),
ArtFuehrung: r.ArtFuehrung,
besucher: Number(r.besucher),
anzahl: Number(r.anzahl),
})),
cumulative: Number(cumulativeRows[0]?.total ?? 0),
tage: Number(tageRows[0]?.tage ?? 0),
allCumulative: Number(allCumulativeRows[0]?.total ?? 0),
allTage: Number(allTageRows[0]?.tage ?? 0),
year,
kuppel,
});
} catch (error) {
console.error('GET /api/statistik:', error);
return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 });
}
}
+11 -3
View File
@@ -1,8 +1,16 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
export async function GET() { export async function GET() {
const temp = Math.round((8 + Math.random() * 15) * 10) / 10; const session = await getSession();
const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10; if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
const res = await fetch('https://stwwetter.fuerst-stuttgart.de/api/weather/latest', { cache: 'no-store' });
if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 });
const data = await res.json();
const temp = Math.round(data.temperature * 10) / 10;
const feuchte = Math.round(data.humidity);
const druck = Math.round(data.pressure);
return NextResponse.json({ temp, feuchte, druck }); return NextResponse.json({ temp, feuchte, druck });
} }
+5
View File
@@ -19,6 +19,10 @@ export async function changePassword(
return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' }; return { error: 'Das Passwort muss mindestens 6 Zeichen lang sein.' };
} }
if (newPassword === 'welzheim') {
return { error: 'Das Standard-Passwort darf nicht als neues Passwort verwendet werden.' };
}
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return { error: 'Die Passwörter stimmen nicht überein.' }; return { error: 'Die Passwörter stimmen nicht überein.' };
} }
@@ -35,6 +39,7 @@ export async function changePassword(
beoName: session.beoName, beoName: session.beoName,
mustChangePassword: false, mustChangePassword: false,
isAuthenticated: true, isAuthenticated: true,
role: session.role ?? null,
}); });
redirect('/'); redirect('/');
+1 -1
View File
@@ -10,7 +10,7 @@ export default function ChangePasswordPage() {
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-[#FFFFDD]"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF4FF]">
<h1 className="text-3xl font-bold mb-6">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold mb-6">Logbuch Sternwarte Welzheim</h1>
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
+10
View File
@@ -24,3 +24,13 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
@media print {
@page {
size: A4 portrait;
margin: 1.5cm;
}
body {
background: white;
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -6,6 +6,11 @@ export const metadata: Metadata = {
description: 'Logbuch für die Sternwarte Welzheim', description: 'Logbuch für die Sternwarte Welzheim',
}; };
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="de"> <html lang="de">
+7 -6
View File
@@ -8,27 +8,28 @@ export async function login(
_prevState: { error: string } | undefined, _prevState: { error: string } | undefined,
formData: FormData formData: FormData
): Promise<{ error: string }> { ): Promise<{ error: string }> {
const kuerzel = (formData.get('username') as string)?.trim(); const login = (formData.get('username') as string)?.trim();
const password = formData.get('password') as string; const password = formData.get('password') as string;
if (!kuerzel || !password) { if (!login || !password) {
return { error: 'Bitte Kürzel und Passwort eingeben.' }; return { error: 'Bitte Kürzel/Nachname und Passwort eingeben.' };
} }
const result = await verifyCredentials(kuerzel, password); const result = await verifyCredentials(login, password);
if (!result || !result.valid) { if (!result || !result.valid) {
return { error: 'Ungültiges Kürzel oder Passwort.' }; return { error: 'Ungültiger Benutzername oder Passwort.' };
} }
const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw; const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw;
await createSession({ await createSession({
kuerzel: result.beo.kürzel ?? kuerzel, kuerzel: result.beo.kürzel ?? login,
beoId: result.beo.id, beoId: result.beo.id,
beoName: getBeoDisplayName(result.beo), beoName: getBeoDisplayName(result.beo),
mustChangePassword: mustChange, mustChangePassword: mustChange,
isAuthenticated: true, isAuthenticated: true,
role: result.beo.role ?? null,
}); });
if (mustChange) { if (mustChange) {
+3 -3
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
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-[#FFFFDD]"> <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-6">
<h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1> <h1 className="text-3xl font-bold">Logbuch Sternwarte Welzheim</h1>
</div> </div>
@@ -27,7 +27,7 @@ export default function LoginPage() {
<form action={loginAction} className="space-y-5"> <form action={loginAction} className="space-y-5">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Kürzel Kürzel oder Nachname
</label> </label>
<input <input
id="username" id="username"
@@ -36,7 +36,7 @@ export default function LoginPage() {
required required
autoComplete="off" autoComplete="off"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm" className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Kürzel" placeholder="Kürzel oder Nachname"
disabled={isPending} disabled={isPending}
/> />
</div> </div>
+1
View File
@@ -11,6 +11,7 @@ export default async function HomePage() {
kuerzel={session.kuerzel} kuerzel={session.kuerzel}
beoId={session.beoId} beoId={session.beoId}
beoName={session.beoName} beoName={session.beoName}
role={session.role ?? null}
/> />
); );
} }
+5 -3
View File
@@ -13,9 +13,10 @@ interface Props {
placeholder: string; placeholder: string;
onChange: (value: string) => void; onChange: (value: string) => void;
keepOpen?: boolean; keepOpen?: boolean;
id?: string;
} }
export default function CustomSelect({ options, placeholder, onChange, keepOpen = false }: Props) { export default function CustomSelect({ options, placeholder, onChange, keepOpen = false, id }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -37,9 +38,10 @@ export default function CustomSelect({ options, placeholder, onChange, keepOpen
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
<button <button
id={id}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-700 focus:border-blue-500 focus:outline-none" className="w-full flex items-center justify-between 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"
> >
<span>{placeholder}</span> <span>{placeholder}</span>
<svg <svg
@@ -58,7 +60,7 @@ export default function CustomSelect({ options, placeholder, onChange, keepOpen
type="button" type="button"
disabled={opt.disabled} disabled={opt.disabled}
onClick={() => select(opt.value)} onClick={() => select(opt.value)}
className="w-full text-left px-4 py-2 text-base hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0 disabled:text-gray-400 disabled:bg-gray-50" className="w-full text-left px-4 py-2 text-base text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0 disabled:text-gray-400 disabled:bg-gray-50"
> >
{opt.label} {opt.label}
</button> </button>
+150 -105
View File
@@ -1,12 +1,12 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch'; import type { Kuppel, ArtFuehrung, BeoOption, SelectedObjekt, Wetter, LogbuchEintrag } from '@/types/logbuch';
import { ARTEN, ARTEN_MAP } from '@/types/logbuch'; import { ARTEN, ARTEN_MAP, artLabel } from '@/types/logbuch';
import BeoSelector from './BeoSelector'; import BeoSelector from './BeoSelector';
import ObjektSelector from './ObjektSelector'; import ObjektSelector from './ObjektSelector';
import CustomSelect from './CustomSelect'; import CustomSelect from './CustomSelect';
import TimePicker5 from './TimePicker5'; import TimeInput from './TimeInput';
interface Props { interface Props {
kuppel: Kuppel; kuppel: Kuppel;
@@ -20,39 +20,10 @@ function toLocalDatetimeValue(isoOrDatetime: string): string {
return isoOrDatetime.slice(0, 16); return isoOrDatetime.slice(0, 16);
} }
function snapTo5(value: string): string {
if (!value) return value;
// Fix 4-digit years that are actually < 100 (e.g. "0024" → "2024")
const fixed = value.replace(/^(\d{4})(-.+)$/, (_, y, rest) => {
const year = parseInt(y, 10);
return (year < 100 ? String(year + 2000) : y) + rest;
});
const d = new Date(fixed);
if (isNaN(d.getTime())) return value;
d.setMinutes(Math.round(d.getMinutes() / 5) * 5);
d.setSeconds(0);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function snapTimeTo5(time: string): string {
if (!time) return time;
const [hStr, mStr] = time.split(':');
const h = parseInt(hStr, 10);
const m = parseInt(mStr, 10);
if (isNaN(h) || isNaN(m)) return time;
const snappedM = Math.round(m / 5) * 5;
const finalH = snappedM >= 60 ? (h + 1) % 24 : h;
const finalM = snappedM >= 60 ? 0 : snappedM;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(finalH)}:${pad(finalM)}`;
}
function nowLocalDatetime(): string { function nowLocalDatetime(): 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');
const raw = `${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())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
return snapTo5(raw);
} }
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD']; const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
@@ -66,11 +37,27 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
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 [wetter, setWetter] = useState<Wetter | null>(null); const [sonderName, setSonderName] = useState('');
const [wetter, setWetter] = useState<Wetter>({ temp: 0, feuchte: 0, druck: 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);
const editEntryRef = useRef(editEntry);
editEntryRef.current = editEntry;
const formRef = useRef<HTMLFormElement>(null);
function focusNext(current: HTMLElement) {
if (!formRef.current) return;
const fields = Array.from(
formRef.current.querySelectorAll<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), #art-select'
)
);
const idx = fields.indexOf(current);
if (idx >= 0) fields[(idx + 1) % fields.length]?.focus();
}
const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung); const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
const isSonne = artFuehrung === SONNE_ART; const isSonne = artFuehrung === SONNE_ART;
@@ -78,7 +65,11 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
useEffect(() => { useEffect(() => {
fetch('/api/wetter') fetch('/api/wetter')
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then(setWetter) .then((w: Wetter) => {
if (!editEntryRef.current) {
setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) });
}
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -89,11 +80,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setEnde(toLocalDatetimeValue(editEntry.Ende)); setEnde(toLocalDatetimeValue(editEntry.Ende));
setBesucher(editEntry.Besucher ?? ''); setBesucher(editEntry.Besucher ?? '');
setBemerkungen(editEntry.Bemerkungen ?? ''); setBemerkungen(editEntry.Bemerkungen ?? '');
setSonderName(editEntry.SonderName ?? '');
if (editEntry.WetterTemp !== null) { if (editEntry.WetterTemp !== null) {
setWetter({ setWetter({
temp: editEntry.WetterTemp ?? 0, temp: editEntry.WetterTemp ?? 0,
feuchte: editEntry.WetterFeuchte ?? 0, feuchte: Math.round(editEntry.WetterFeuchte ?? 0),
druck: editEntry.WetterDruck ?? 0, druck: Math.round(editEntry.WetterDruck ?? 0),
}); });
} }
} else { } else {
@@ -104,6 +96,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
setBeos([currentUserBeo]); setBeos([currentUserBeo]);
setObjekte([]); setObjekte([]);
setBemerkungen(''); setBemerkungen('');
setSonderName('');
setBesucher(''); setBesucher('');
} }
}, [editEntry, currentUserBeo]); }, [editEntry, currentUserBeo]);
@@ -144,13 +137,24 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setSaving(true);
setError(''); setError('');
setSuccess(false); setSuccess(false);
if (showBesucher && besucher === '') {
setError('Bitte Besucherzahl eingeben.');
return;
}
if (beginn === ende) {
setError('Die Zeite wurden nicht eingegeben');
return;
}
setSaving(true);
const body = { const body = {
Kuppel: kuppel, Kuppel: kuppel,
ArtFuehrung: artFuehrung, ArtFuehrung: artFuehrung,
SonderName: artFuehrung === 'SF' ? sonderName : null,
Beginn: beginn, Beginn: beginn,
Ende: ende, Ende: ende,
Besucher: besucher === '' ? 0 : besucher, Besucher: besucher === '' ? 0 : besucher,
@@ -188,67 +192,69 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
} }
} }
const inputCls = 'w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none'; const inputCls = 'w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none';
const labelCls = 'block text-sm font-medium text-gray-700 mb-0.5'; const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5';
return ( return (
<form onSubmit={handleSubmit} className="space-y-3 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-4 bg-white"> <form
ref={formRef}
onSubmit={handleSubmit}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
const tag = (e.target as HTMLElement).tagName;
if (tag === 'BUTTON' || tag === 'TEXTAREA') return;
e.preventDefault();
focusNext(e.target as HTMLElement);
}}
className="space-y-2 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-3 bg-white"
>
{/* Art der Führung — volle Breite */} {/* Art der Führung / Datum / Startzeit / Endzeit / Besucher — eine Zeile */}
<div> <div className="flex flex-wrap gap-3 items-end">
<label className={labelCls}>Art der Führung</label> <div className="flex-1 min-w-[160px]">
<CustomSelect <label className={labelCls}>Art der Führung</label>
placeholder={`${artFuehrung}${ARTEN_MAP[artFuehrung]}`} <CustomSelect
options={ARTEN.map((a) => ({ value: a, label: `${a}${ARTEN_MAP[a]}` }))} id="art-select"
onChange={(v) => setArtFuehrung(v as ArtFuehrung)} placeholder={artLabel(artFuehrung)}
/> options={ARTEN.map((a) => ({ value: a, label: artLabel(a) }))}
</div> onChange={(v) => setArtFuehrung(v as ArtFuehrung)}
/>
{/* Beginn / Ende / Besucher */}
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<label className={labelCls}>Beginn</label>
<div className="flex gap-2">
<input
type="date"
value={beginn.slice(0, 10)}
onChange={(e) => {
if (!e.target.value) return;
setBeginn(e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'));
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
}}
required
className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
<TimePicker5
value={beginn.slice(11, 16)}
onChange={(t) => setBeginn(beginn.slice(0, 10) + 'T' + t)}
className="w-24"
/>
</div>
</div> </div>
<div className="w-full sm:flex-1"> <div className="shrink-0">
<label className={labelCls}>Ende</label> <label className={labelCls}>Datum</label>
<div className="flex gap-2"> <input
<input type="date"
type="date" value={beginn.slice(0, 10)}
value={ende.slice(0, 10)} onChange={(e) => {
onChange={(e) => { if (!e.target.value) return;
if (!e.target.value) return; setBeginn(e.target.value + 'T' + (beginn.slice(11, 16) || '00:00'));
setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00')); setEnde(e.target.value + 'T' + (ende.slice(11, 16) || '00:00'));
}} }}
required required
className="flex-1 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none" className="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"
/> />
<TimePicker5 </div>
value={ende.slice(11, 16)} <div className="shrink-0">
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)} <label className={labelCls}>Startzeit</label>
className="w-24" <TimeInput
/> value={beginn.slice(11, 16)}
</div> onChange={(t) => {
setBeginn(beginn.slice(0, 10) + 'T' + t);
setEnde(ende.slice(0, 10) + 'T' + t);
}}
className="w-24"
/>
</div>
<div className="shrink-0">
<label className={labelCls}>Endzeit</label>
<TimeInput
value={ende.slice(11, 16)}
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
className="w-24"
/>
</div> </div>
{showBesucher && ( {showBesucher && (
<div className="sm:shrink-0"> <div className="shrink-0">
<label className={labelCls}>Besucher</label> <label className={labelCls}>Besucher</label>
<input <input
type="number" type="number"
@@ -256,12 +262,27 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)} onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)}
min={0} min={0}
max={9999} max={9999}
className="w-20 px-2 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none" className="w-20 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/> />
</div> </div>
)} )}
</div> </div>
{/* Name/Gruppe bei Sonderführung */}
{artFuehrung === 'SF' && (
<div>
<label className={labelCls}>Name / Gruppe</label>
<input
type="text"
value={sonderName}
onChange={(e) => setSonderName(e.target.value.slice(0, 200))}
maxLength={200}
placeholder="Name oder Gruppe der Sonderführung"
className="w-full 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>
)}
{/* BEOs */} {/* BEOs */}
<div> <div>
<label className={labelCls}>BEOs</label> <label className={labelCls}>BEOs</label>
@@ -295,22 +316,46 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
value={bemerkungen} value={bemerkungen}
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))} onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
rows={2} rows={2}
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none resize-y" className="w-full px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none resize-y"
placeholder="Freier Text (max. 500 Zeichen)" placeholder="Freier Text (max. 500 Zeichen)"
/> />
</div> </div>
{/* Wetter */} {/* Wetter */}
{wetter && ( <div>
<div> <div className="grid grid-cols-3">
<label className={labelCls}>Wetter (aktuell)</label> <div>
<div className="flex flex-wrap gap-4 text-sm text-gray-600 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2"> <label className="block text-xs text-gray-500 mb-0.5">Temperatur (°C)</label>
<span>🌡 {wetter.temp} °C</span> <input
<span>💧 {wetter.feuchte} %</span> type="number"
<span>🌬 {wetter.druck} hPa</span> value={wetter.temp}
onChange={(e) => setWetter({ ...wetter, temp: parseFloat(e.target.value) || 0 })}
step="0.1"
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 className="flex flex-col items-center">
<label className="block text-xs text-gray-500 mb-0.5">Feuchte (%)</label>
<input
type="number"
value={wetter.feuchte}
onChange={(e) => setWetter({ ...wetter, feuchte: parseInt(e.target.value) || 0 })}
step="1"
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 className="flex flex-col items-end">
<label className="block text-xs text-gray-500 mb-0.5">Luftdruck (hPa)</label>
<input
type="number"
value={wetter.druck}
onChange={(e) => setWetter({ ...wetter, druck: parseInt(e.target.value) || 0 })}
step="1"
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>
</div> </div>
)} </div>
{error && ( {error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm"> <div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
@@ -328,7 +373,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="w-full sm:w-auto px-6 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-base" className="w-full sm:w-auto px-6 py-1 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
> >
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'} {saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
</button> </button>
@@ -336,7 +381,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
<button <button
type="button" type="button"
onClick={onSaved} onClick={onSaved}
className="w-full sm:w-auto px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-base" className="w-full sm:w-auto px-6 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
> >
Abbrechen Abbrechen
</button> </button>
+155 -52
View File
@@ -11,36 +11,73 @@ interface Props {
compact?: boolean; compact?: boolean;
} }
function formatDateTime(dt: string, short = false): string { const pad = (n: number) => String(n).padStart(2, '0');
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
return `${MONATE[m - 1]} ${y}`;
}
function prevMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 1 ? `${y - 1}-12` : `${y}-${pad(m - 1)}`;
}
function nextMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
}
function formatDate(dt: string, short = false): string {
if (!dt) return ''; if (!dt) return '';
const d = new Date(dt); const d = new Date(dt);
if (isNaN(d.getTime())) return dt; if (isNaN(d.getTime())) return dt;
if (short) { if (short) return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}. ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
} }
export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, compact = false }: Props) { function formatTime(dt: string): string {
if (!dt) return '';
const d = new Date(dt);
if (isNaN(d.getTime())) return dt;
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 10, compact = false }: Props) {
const [entries, setEntries] = useState<LogbuchEintrag[]>([]); const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [month, setMonth] = useState(compact ? '' : currentMonth());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}`) const offset = page * limit;
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
(month ? `&month=${encodeURIComponent(month)}` : '');
fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => { setEntries(data); setLoading(false); }) .then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); })
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); }); .catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
}, [kuppel, refreshKey, limit]); }, [kuppel, refreshKey, limit, page, month]);
async function confirmDelete(id: number) { async function confirmDelete(id: number) {
try { try {
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(); if (!res.ok) throw new Error();
setEntries((prev) => prev.filter((e) => e.ID !== id)); setEntries((prev) => prev.filter((e) => e.ID !== id));
setTotal((t) => t - 1);
} catch { } catch {
setError('Fehler beim Löschen.'); setError('Fehler beim Löschen.');
} finally { } finally {
@@ -48,9 +85,39 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co
} }
} }
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>; const monthNav = !compact && (
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>; <div className="flex items-center gap-2 mb-3 print:hidden">
if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>; <button
onClick={() => setMonth((m) => prevMonth(m))}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300"
></button>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
/>
<button
onClick={() => setMonth((m) => nextMonth(m))}
disabled={month >= currentMonth()}
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
></button>
{month !== currentMonth() && (
<button
onClick={() => setMonth(currentMonth())}
className="text-sm text-blue-600 hover:underline"
>
Aktueller Monat
</button>
)}
</div>
);
const printHeader = !compact && (
<div className="hidden print:block mb-3 text-sm font-semibold">
Monat: {monthLabel(month)}
</div>
);
const cell = compact const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs' ? 'px-1.5 py-1 border border-gray-200 text-xs'
@@ -59,49 +126,79 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold' ? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
: 'px-3 py-2 border border-gray-300'; : 'px-3 py-2 border border-gray-300';
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
return ( return (
<div> <div>
{monthNav}
{printHeader}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}> <table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
<thead> <thead>
<tr className="bg-gray-100 text-left"> <tr className="bg-gray-100 text-left">
<th className={`${head} whitespace-nowrap`}>Beginn</th> <th className={`${head} whitespace-nowrap`}>Datum</th>
<th className={`${head} whitespace-nowrap`}>Ende</th> {compact ? (
<>
<th className={`${head} whitespace-nowrap`}>Start</th>
<th className={`${head} whitespace-nowrap`}>Ende</th>
</>
) : (
<th className={`${head} whitespace-nowrap text-center`}>Zeit</th>
)}
<th className={head}>Art</th> <th className={head}>Art</th>
<th className={`${head} text-center`}>Besucher</th> <th className={`${head} text-center w-10`}>Bes.</th>
<th className={head}>BEOs</th> <th className={head}>BEOs</th>
<th className={head}>Objekte</th> <th className={head}>Objekte</th>
{!compact && <th className={head}>Bemerkungen</th>} {!compact && <th className={head}>Bemerkungen</th>}
<th className={`${head} text-center`}>Aktionen</th> {!compact && <th className={head}>Wetter</th>}
<th className={`${head} text-center print:hidden`}>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.map((e) => ( {entries.length === 0 ? (
<tr>
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
Keine Einträge für {monthLabel(month)}.
</td>
</tr>
) : entries.map((e) => (
<tr key={e.ID} className="hover:bg-gray-50"> <tr key={e.ID} className="hover:bg-gray-50">
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Beginn, compact)}</td> <td className={`${cell} whitespace-nowrap`}>{formatDate(e.Beginn, compact)}</td>
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Ende, compact)}</td> {compact ? (
<td className={cell}>{e.ArtFuehrung}</td> <>
<td className={`${cell} text-center`}>{e.Besucher}</td> <td className={`${cell} whitespace-nowrap`}>{formatTime(e.Beginn)}</td>
<td className={cell}>{e.BEOs || '—'}</td> <td className={`${cell} whitespace-nowrap`}>{formatTime(e.Ende)}</td>
<td className={cell}>{e.Objekte || '—'}</td> </>
{!compact && ( ) : (
<td className={`${cell} max-w-xs`}> <td className={`${cell} whitespace-nowrap text-center`}>
<span className="line-clamp-2">{e.Bemerkungen || ''}</span> <div>{formatTime(e.Beginn)}</div>
<div className="text-gray-400 leading-none"></div>
<div>{formatTime(e.Ende)}</div>
</td> </td>
)} )}
<td className={`${cell} text-center whitespace-nowrap`}> <td className={cell}>
<button <div>{e.ArtFuehrung}</div>
onClick={() => onEdit(e)} {e.SonderName && <div className="text-xs text-gray-500">{e.SonderName}</div>}
className="text-blue-600 hover:text-blue-800 mr-2 font-medium" </td>
> <td className={`${cell} text-center`}>{e.Besucher || ''}</td>
<td className={cell}>{e.BEOs || '—'}</td>
</button> <td className={cell}>{e.Objekte || '—'}</td>
<button {!compact && <td className={cell}>{e.Bemerkungen || ''}</td>}
onClick={() => setDeleteId(e.ID)} {!compact && (
className="text-red-600 hover:text-red-800 font-medium" <td className={cell}>
> {e.WetterTemp !== null && (
<div className="text-xs whitespace-nowrap">
</button> <div>{e.WetterTemp} °C</div>
<div>{Math.round(e.WetterFeuchte ?? 0)} %</div>
<div>{Math.round(e.WetterDruck ?? 0)} hPa</div>
</div>
)}
</td>
)}
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium"></button>
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium"></button>
</td> </td>
</tr> </tr>
))} ))}
@@ -109,24 +206,30 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, co
</table> </table>
</div> </div>
{!compact && total > limit && (
<div className="flex items-center justify-center gap-3 mt-3 print:hidden">
<button
onClick={() => setPage((p) => p - 1)}
disabled={page === 0}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
> Zurück</button>
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * limit >= total}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
>Weiter </button>
</div>
)}
{deleteId !== null && ( {deleteId !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4"> <div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3> <h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p> <p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button <button onClick={() => setDeleteId(null)} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm">Abbrechen</button>
onClick={() => setDeleteId(null)} <button onClick={() => confirmDelete(deleteId)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">Löschen</button>
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm"
>
Abbrechen
</button>
<button
onClick={() => confirmDelete(deleteId)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm"
>
Löschen
</button>
</div> </div>
</div> </div>
</div> </div>
+53 -18
View File
@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch'; import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
import CustomSelect from './CustomSelect';
interface Props { interface Props {
selected: SelectedObjekt[]; selected: SelectedObjekt[];
@@ -11,8 +10,11 @@ interface Props {
export default function ObjektSelector({ selected, onChange }: Props) { export default function ObjektSelector({ selected, onChange }: Props) {
const [all, setAll] = useState<ObjektOption[]>([]); const [all, setAll] = useState<ObjektOption[]>([]);
const [search, setSearch] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [showNewInput, setShowNewInput] = useState(false); const [showNewInput, setShowNewInput] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
fetch('/api/objekte') fetch('/api/objekte')
@@ -21,20 +23,37 @@ export default function ObjektSelector({ selected, onChange }: Props) {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [dropdownOpen]);
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase())); const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase())); const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
const filtered = search
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
: available;
function add(value: string) { function add(obj: ObjektOption) {
const obj = all.find((o) => o.ID === parseInt(value)); onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
if (obj && !selectedNames.has(obj.Name.toLowerCase())) { setSearch('');
onChange([...selected, { ID: obj.ID, Name: obj.Name }]); setDropdownOpen(false);
}
} }
function addNew() { function addNew() {
const name = newName.trim(); const name = newName.trim();
if (!name || selectedNames.has(name.toLowerCase())) return; if (!name || selectedNames.has(name.toLowerCase())) return;
onChange([...selected, { ID: null, Name: name }]); const existing = all.find((o) => o.Name.toLowerCase() === name.toLowerCase());
if (existing) {
onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
} else {
onChange([...selected, { ID: null, Name: name }]);
}
setNewName(''); setNewName('');
setShowNewInput(false); setShowNewInput(false);
} }
@@ -66,19 +85,35 @@ export default function ObjektSelector({ selected, onChange }: Props) {
<div className="flex gap-2"> <div className="flex gap-2">
{available.length > 0 && ( {available.length > 0 && (
<div className="flex-1"> <div ref={wrapperRef} className="relative flex-1">
<CustomSelect <input
placeholder="+ Objekte hinzufügen" type="text"
options={available.map((o) => ({ value: String(o.ID), label: o.Name }))} value={search}
onChange={add} onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
keepOpen onFocus={() => setDropdownOpen(true)}
placeholder="Objekt suchen..."
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
/> />
{dropdownOpen && filtered.length > 0 && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filtered.map((o) => (
<button
key={o.ID}
type="button"
onClick={() => add(o)}
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
>
{o.Name}
</button>
))}
</div>
)}
</div> </div>
)} )}
<button <button
type="button" type="button"
onClick={() => setShowNewInput((v) => !v)} onClick={() => setShowNewInput((v) => !v)}
className="px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-700 hover:bg-gray-50 whitespace-nowrap" className="px-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-700 hover:bg-gray-50 whitespace-nowrap"
> >
+ Neu + Neu
</button> </button>
@@ -92,20 +127,20 @@ export default function ObjektSelector({ selected, onChange }: Props) {
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } }}
placeholder="Objektname eingeben" placeholder="Objektname eingeben"
className="flex-1 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none" className="flex-1 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
autoFocus autoFocus
/> />
<button <button
type="button" type="button"
onClick={addNew} onClick={addNew}
className="px-4 py-2 bg-green-600 text-white text-base rounded-lg hover:bg-green-700" className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700"
> >
OK OK
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setShowNewInput(false); setNewName(''); }} onClick={() => { setShowNewInput(false); setNewName(''); }}
className="px-4 py-2 bg-gray-200 text-gray-700 text-base rounded-lg hover:bg-gray-300" className="px-4 py-2 bg-gray-200 text-gray-700 text-sm rounded-lg hover:bg-gray-300"
> >
</button> </button>
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import type { Kuppel } from '@/types/logbuch';
import { artLabel } from '@/types/logbuch';
interface MonthlyRow {
monat: number;
ArtFuehrung: string;
besucher: number;
anzahl: number;
}
interface StatsData {
monthly: MonthlyRow[];
cumulative: number;
tage: number;
allCumulative: number;
allTage: number;
year: number;
kuppel: Kuppel;
}
const MONATE = [
'Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember',
];
interface Props {
kuppel: Kuppel;
}
export default function Statistik({ kuppel }: Props) {
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<StatsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
setError('');
fetch(`/api/statistik?kuppel=${encodeURIComponent(kuppel)}&year=${year}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: StatsData) => { setData(d); setLoading(false); })
.catch(() => { setError('Fehler beim Laden der Statistik.'); setLoading(false); });
}, [kuppel, year]);
const { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal } = useMemo(() => {
if (!data) {
return { arten: [] as string[], matrix: [] as (number | null)[][], monatTotal: [] as number[], artTotal: [] as number[], grandTotal: 0, anzahlTotal: [] as number[] };
}
const artenSet = new Set<string>();
data.monthly.forEach((r) => artenSet.add(r.ArtFuehrung));
const arten = Array.from(artenSet).sort();
const matrix: (number | null)[][] = [];
const monatTotal: number[] = [];
const anzahlTotal: number[] = [];
const artTotal: number[] = new Array(arten.length).fill(0);
let grandTotal = 0;
for (let m = 1; m <= 12; m++) {
const row: (number | null)[] = [];
let mSum = 0;
let aSum = 0;
arten.forEach((art, idx) => {
const found = data.monthly.find((r) => r.monat === m && r.ArtFuehrung === art);
const val = found ? found.besucher : null;
row.push(val);
if (val !== null) {
mSum += val;
artTotal[idx] += val;
aSum += found!.anzahl;
}
});
matrix.push(row);
monatTotal.push(mSum);
anzahlTotal.push(aSum);
grandTotal += mSum;
}
return { arten, matrix, monatTotal, artTotal, grandTotal, anzahlTotal };
}, [data]);
if (loading) return <div className="text-gray-500 text-sm py-4">Lade Statistik...</div>;
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
const headCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 whitespace-nowrap';
const cellCls = 'px-3 py-2 border border-gray-200 text-sm text-right tabular-nums';
const labelCls = 'px-3 py-2 border border-gray-200 text-sm text-left whitespace-nowrap';
return (
<div className="space-y-4">
<div className="flex items-center gap-3 print:hidden">
<label className="text-sm font-medium text-gray-700">Jahr</label>
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
min={2000}
max={2100}
className="w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm focus:border-blue-500 focus:outline-none"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className={headCls}>Monat</th>
<th className={headCls}>Führungen</th>
{arten.map((art) => (
<th key={art} className={headCls}>{artLabel(art as any) || art}</th>
))}
<th className={headCls}>Gesamt</th>
</tr>
</thead>
<tbody>
{MONATE.map((name, idx) => {
const mSum = monatTotal[idx];
const aSum = anzahlTotal[idx];
return (
<tr key={name} className={mSum > 0 ? '' : 'text-gray-400'}>
<td className={labelCls}>{name}</td>
<td className={cellCls}>{aSum > 0 ? aSum : ''}</td>
{arten.map((_, aIdx) => {
const val = matrix[idx][aIdx];
return (
<td key={aIdx} className={cellCls}>
{val !== null && val > 0 ? val.toLocaleString('de-DE') : ''}
</td>
);
})}
<td className={`${cellCls} font-semibold`}>{mSum > 0 ? mSum.toLocaleString('de-DE') : ''}</td>
</tr>
);
})}
<tr className="bg-gray-50 font-semibold">
<td className={labelCls}>Summe</td>
<td className={cellCls}>{anzahlTotal.reduce((s, v) => s + v, 0) > 0 ? anzahlTotal.reduce((s, v) => s + v, 0).toLocaleString('de-DE') : ''}</td>
{artTotal.map((t, i) => (
<td key={i} className={cellCls}>{t > 0 ? t.toLocaleString('de-DE') : ''}</td>
))}
<td className={cellCls}>{grandTotal > 0 ? grandTotal.toLocaleString('de-DE') : ''}</td>
</tr>
</tbody>
</table>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 w-full">
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Kumulierte Besucher {year} ({data?.kuppel})</div>
<div className="text-2xl font-bold">{data?.cumulative.toLocaleString('de-DE') ?? 0}</div>
</div>
<div className="border-2 border-gray-300 rounded-xl p-4 bg-white">
<div className="text-xs text-gray-500 mb-1">Führungstage {year} ({data?.kuppel})</div>
<div className="text-2xl font-bold">{data?.tage ?? 0}</div>
</div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50">
<div className="text-xs text-green-700 mb-1">Kumulierte Besucher {year} (Sternwarte gesamt)</div>
<div className="text-2xl font-bold text-green-800">{data?.allCumulative.toLocaleString('de-DE') ?? 0}</div>
</div>
<div className="border-2 border-green-600 rounded-xl p-4 bg-green-50">
<div className="text-xs text-green-700 mb-1">Führungstage {year} (Sternwarte gesamt)</div>
<div className="text-2xl font-bold text-green-800">{data?.allTage ?? 0}</div>
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { useEffect, useState } from 'react';
interface Props {
value: string; // "HH:MM"
onChange: (value: string) => void;
className?: string;
}
function isValid(t: string): boolean {
if (!/^\d{1,2}:\d{2}$/.test(t)) return false;
const [h, m] = t.split(':').map(Number);
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
}
function normalize(t: string): string {
const [h, m] = t.split(':').map(Number);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export default function TimeInput({ value, onChange, className = '' }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
useEffect(() => {
setLocal(value);
setError(false);
}, [value]);
function handleBlur() {
if (isValid(local)) {
const norm = normalize(local);
setLocal(norm);
setError(false);
onChange(norm);
} else {
setError(true);
}
}
return (
<div className={`relative ${className}`}>
<input
type="text"
value={local}
onChange={(e) => { setLocal(e.target.value); setError(false); }}
onBlur={handleBlur}
placeholder="HH:MM"
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 ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
}`}
/>
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungültig (00:00 23:59)
</p>
)}
</div>
);
}
+1 -1
View File
@@ -58,7 +58,7 @@ export default function TimePicker5({ value, onChange, className = '' }: Props)
}} }}
className={`flex items-center border-2 border-gray-400 rounded-lg bg-white focus:border-blue-500 focus:outline-none select-none ${className}`} className={`flex items-center border-2 border-gray-400 rounded-lg bg-white focus:border-blue-500 focus:outline-none select-none ${className}`}
> >
<span className="flex-1 px-3 py-2 text-sm font-mono text-center">{value}</span> <span className="flex-1 px-3 py-2 text-sm font-mono text-center text-gray-900">{value}</span>
<div className="flex flex-col border-l border-gray-300 shrink-0"> <div className="flex flex-col border-l border-gray-300 shrink-0">
<button {...buttonProps(5)} className="px-2 pt-1 pb-0.5 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button> <button {...buttonProps(5)} className="px-2 pt-1 pb-0.5 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button>
<button {...buttonProps(-5)} className="px-2 pt-0.5 pb-1 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button> <button {...buttonProps(-5)} className="px-2 pt-0.5 pb-1 hover:bg-gray-100 text-gray-500 text-xs leading-none"></button>
+99
View File
@@ -0,0 +1,99 @@
services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
ports:
- "127.0.0.1:3336:3306"
networks:
- proxy
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
- -uroot
- -p${DB_ROOT_PASS}
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
depends_on:
logbuch_mysql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.logbuch-pma.entrypoints=http
- traefik.http.routers.logbuch-pma.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.middlewares.logbuch-pma-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.logbuch-pma.middlewares=logbuch-pma-https-redirect
- traefik.http.routers.logbuch-pma-secure.entrypoints=https
- traefik.http.routers.logbuch-pma-secure.rule=Host(`logbuch.fuerst-stuttgart.de`) && PathPrefix(`/myadmin`)
- traefik.http.routers.logbuch-pma-secure.tls=true
- traefik.http.routers.logbuch-pma-secure.middlewares=logbuch-pma-slash,logbuch-pma-strip
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.regex=^https://logbuch\.fuerst-stuttgart\.de/myadmin$$
- traefik.http.middlewares.logbuch-pma-slash.redirectregex.replacement=https://logbuch.fuerst-stuttgart.de/myadmin/
- traefik.http.middlewares.logbuch-pma-strip.stripprefix.prefixes=/myadmin
- traefik.http.routers.logbuch-pma-secure.service=logbuch-pma
- traefik.http.services.logbuch-pma.loadbalancer.server.port=80
networks:
- proxy
logbuch_app:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
environment:
DB_HOST: logbuch_mysql
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
DB_PORT: 3306
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
ports:
- 127.0.0.1:${APP_PORT:-3000}:3000
depends_on:
logbuch_mysql:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.http.routers.logbuch.entrypoints=http
- traefik.http.routers.logbuch.rule=Host(`logbuch.fuerst-stuttgart.de`)
- traefik.http.middlewares.logbuch-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.logbuch.middlewares=logbuch-https-redirect
- traefik.http.routers.logbuch-secure.entrypoints=https
- traefik.http.routers.logbuch-secure.rule=Host(`logbuch.fuerst-stuttgart.de`)
- traefik.http.routers.logbuch-secure.tls=true
- traefik.http.routers.logbuch-secure.service=logbuch
- traefik.http.services.logbuch.loadbalancer.server.port=3000
networks:
- proxy
- gitea-internal
networks:
proxy:
name: dockge_default
external: true
gitea-internal:
name: gitea_gitea-internal
external: true
volumes:
db_data: null
+6
View File
@@ -37,6 +37,12 @@ docker buildx build \
--push \ --push \
. .
echo ">>> Tagge ${IMAGE_NAME} als :latest..."
docker buildx imagetools create \
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
"${FULL_IMAGE}"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Deploy erfolgreich abgeschlossen!" echo "Deploy erfolgreich abgeschlossen!"
+64
View File
@@ -0,0 +1,64 @@
services:
logbuch_mysql:
image: mysql:lts
container_name: logbuch_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
networks:
- logbuch_net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASS}"]
interval: 10s
timeout: 5s
retries: 10
# Kein Port nach außen — nur internes Netzwerk
logbuch_phpmyadmin:
image: phpmyadmin:latest
container_name: logbuch_phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: logbuch_mysql
PMA_PORT: 3306
PMA_ABSOLUTE_URI: https://logbuch.fuerst-stuttgart.de/myadmin/
ports:
- "127.0.0.1:${PMA_PORT:-8080}:80"
depends_on:
logbuch_mysql:
condition: service_healthy
networks:
- logbuch_net
logbuch_app:
image: docker.citysensor.de/logbuch:latest
container_name: logbuch_app
restart: unless-stopped
environment:
DB_HOST: logbuch_mysql
DB_PORT: 3306
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
AUTH_SECRET: ${AUTH_SECRET}
NODE_ENV: production
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on:
logbuch_mysql:
condition: service_healthy
networks:
- logbuch_net
networks:
logbuch_net:
driver: bridge
volumes:
db_data:
+17 -4
View File
@@ -8,25 +8,38 @@ export interface Beo {
kürzel: string | null; kürzel: string | null;
pw: string | null; pw: string | null;
MustChangePassword: number; MustChangePassword: number;
role: string | null;
} }
export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> { export async function getBeoByKuerzel(kuerzel: string): Promise<Beo | null> {
const rows = await query( const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword FROM beos WHERE `kürzel` = ?', 'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE `kürzel` = ?',
[kuerzel] [kuerzel]
) as Beo[]; ) as Beo[];
return rows[0] ?? null; return rows[0] ?? null;
} }
export async function getBeoByLogin(login: string): Promise<Beo | null> {
// First try exact Kürzel match, then case-insensitive Nachname match
const byKuerzel = await getBeoByKuerzel(login);
if (byKuerzel) return byKuerzel;
const rows = await query(
'SELECT id, name, vorname, `kürzel`, pw, MustChangePassword, role FROM beos WHERE LOWER(name) = LOWER(?)',
[login]
) as Beo[];
return rows[0] ?? null;
}
export async function verifyCredentials( export async function verifyCredentials(
kuerzel: string, login: string,
password: string password: string
): Promise<{ beo: Beo; valid: boolean } | null> { ): Promise<{ beo: Beo; valid: boolean } | null> {
const beo = await getBeoByKuerzel(kuerzel); const beo = await getBeoByLogin(login);
if (!beo) return null; if (!beo) return null;
if (!beo.pw) { if (!beo.pw) {
const valid = password === 'logbuch123'; const valid = password === 'welzheim';
return { beo, valid }; return { beo, valid };
} }
+2
View File
@@ -3,9 +3,11 @@ import type { QueryResult } from 'mysql2/promise';
const dbConfig = { const dbConfig = {
host: process.env.DB_HOST || 'mydbase_mysql', host: process.env.DB_HOST || 'mydbase_mysql',
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME || 'logbuch', database: process.env.DB_NAME || 'logbuch',
charset: 'utf8mb4',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
+1
View File
@@ -14,6 +14,7 @@ export interface SessionData {
mustChangePassword: boolean; mustChangePassword: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
expiresAt: number; expiresAt: number;
role: string | null;
} }
async function encrypt(payload: SessionData): Promise<string> { async function encrypt(payload: SessionData): Promise<string> {
+193
View File
@@ -0,0 +1,193 @@
-- MySQL dump 10.13 Distrib 8.4.3, for Linux (aarch64)
--
-- Host: localhost Database: sternwarte
-- ------------------------------------------------------
-- Server version 8.4.3
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `beos`
--
DROP TABLE IF EXISTS `beos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `beos` (
`id` int NOT NULL,
`name` varchar(20) NOT NULL,
`vorname` varchar(20) DEFAULT NULL,
`kürzel` varchar(5) DEFAULT NULL,
`adresse` varchar(50) NOT NULL,
`plz` varchar(10) NOT NULL,
`ort` varchar(30) NOT NULL,
`email_1` varchar(40) NOT NULL,
`email_2` varchar(40) DEFAULT NULL,
`telefon_p` varchar(20) DEFAULT NULL,
`telefon_m` varchar(20) DEFAULT NULL,
`telefon_d` varchar(20) DEFAULT NULL,
`telefon_fax` varchar(20) DEFAULT NULL,
`weburl` varchar(30) DEFAULT NULL,
`gender` char(1) NOT NULL,
`schluesselnr` int DEFAULT NULL,
`gruppe` varchar(20) DEFAULT NULL,
`bemerkung` varchar(50) DEFAULT NULL,
`pw` varchar(70) DEFAULT NULL,
`MustChangePassword` tinyint(1) DEFAULT '1',
`role` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `beos`
--
LOCK TABLES `beos` WRITE;
/*!40000 ALTER TABLE `beos` DISABLE KEYS */;
INSERT INTO `beos` VALUES (1,'Brückner','Steffen','Brü','Hegelstr. 10','71093','Weil im Schönbuch','brueckner@ccdastro.de',NULL,'070312627550',NULL,NULL,'070312627551',NULL,'m',2,'Mi II,Sa A',NULL,NULL,0,'guide, key'),(2,'Dschida','Hans','HD','Rain 5','73660','Urbach','hansdschida1@gmail.com',NULL,'0718184322','015575359828','07195142599',NULL,NULL,'m',21,'Mi I,Sa C',NULL,'$2b$10$AvNL4FSmTrD6.TfC3D5Y7uD8Erl1fShtpxy9FqajohRkwb6h1F1uS',0,'guide, key'),(5,'Ess','Andrea','Ess','Beethovenweg 8','73630','Remshalden','andrea.ess@t-online.de',NULL,'071512703929',NULL,'07151566486',NULL,NULL,'w',NULL,NULL,NULL,NULL,0,'guide'),(6,'Förnzler','Ulrich','','Ober Str. 12','7ß190','Stuttgart','papa.foernzler@gmx.de',NULL,'07112865188','01778239801',NULL,NULL,NULL,'m',12,NULL,NULL,NULL,0,'key'),(7,'Fürst','Reinhard','rxf','Forststr. 66a','70176','Stuttgart','rexfue@gmail.com',NULL,'07116369409','01713129481',NULL,NULL,'','m',4,'Mo I,Sa C',NULL,'$2b$10$nmpF4s1rgeVF.6o1Nv7nk.OvAlcEJKsrAWeiqgESyBcKtvxw0fZNS',0,'guide, key, admin'),(8,'Gertz','Martin','MG','Buhlstr. 39/1','71384','Weinstadt','martin.gertz@gmx.de','martin.gertz@stihl.de','071519459521','015788298545','07151262545','071512682545',NULL,'m',5,'Sa B','HOBS','$2b$10$OWIMz/AFBURTz428C7CvB.NUXo2OuGfAU3nuGl/S.DESYeKcoIugm',0,'guide, key'),(9,'Meyer-Hamme','Olaf','MH','Eichenweg 29','73650','Winterbach','olaf.meyer-hamme@gmx.de',NULL,'071814808656','015221962790','07181977050',NULL,NULL,'m',22,'Mi II,Sa C',NULL,NULL,0,'guide, key'),(10,'Idler','Rudolf','ID','Stettener Str. 26/1','71394','Kernen','r.idler@freenet.de',NULL,NULL,'01782097963','07119576017',NULL,NULL,'m',11,'Sa A',NULL,NULL,0,'key'),(11,'Nikolaizig','Jörg','JN','Grundweinberge 22','71642','Ludwigsburg','joniko@web.de',NULL,'07141257447','015122894226','071316444249',NULL,NULL,'m',10,'Mo II,Sa A',NULL,'$2b$10$issn3Zd2Jp959ujM61S5gewaQPZzmEb7jSDyqVAMsCTYvCdNpNdkq',0,'guide, key'),(15,'Weishaar','Christoph','CW','Leintelstr. 48','71336','Waiblingen-Bittenfeld','erfinderwerkstatt@t-online.de','c.weishaar@pilz.de ','071462840172',NULL,'07113409216','07113409434',NULL,'m',7,'Mi I,Sa B',NULL,'$2b$10$wSc60txkGL8cvNEZeF8IS.Yzfm8xvM1yJ/EbbRJRI5GkK3ERpkesW',0,'guide, key'),(16,'Zoller','Matthias','Zo','Rosenstr. 49','71063','Sindelfingen','mazoller@gmx.de',NULL,'07031876466','01713752637','071197242618',NULL,NULL,'m',8,'Mo II,Sa A',NULL,NULL,0,'guide, key'),(17,'Keller','Hans-Ulrich','HUK','Planetarium','','Stuttgart','hans-ulrich.keller@stuttgart.de','planetarium@stuttgart.de','07114403350',NULL,'07111629226','07112163912',NULL,'m',1,NULL,NULL,'$2b$10$tyMkggLaVOwZMsetcFExK.kTjOATYWg0zBPp/BGVHy84CaCf7hIfa',0,'guide, key'),(18,'Gräber','Hubert','HHG','Im Brunnengarten 20','73630','Remshalden','hubert.graeber@t-online.de',NULL,'0718141612',NULL,NULL,'0718146145',NULL,'m',6,'Mo I,Sa C',NULL,'$2b$10$v.kI/NKI7jnhBGizQlagcOOyp679fBI2vcZ6d/ZF6htn9KAIgY176',0,'guide, key'),(19,'Mitterhuber','Markus','MM','Robert-Koch-Str. 132','70565','Stuttgart','st155087@stud.uni-stuttgart.de','markusmitterhuber@outlook.de',NULL,'016092976568',NULL,NULL,NULL,'m',14,'Sa B',NULL,NULL,0,'deleted'),(20,'Schneider','Eva','ES','Baumblüte 20','73642','Welzheim','schneider-welzheim@t-online.de',NULL,'07182935424','01727168353',NULL,NULL,NULL,'w',NULL,'Sa B',NULL,NULL,0,'guide, key'),(21,'Güssmann','Marc','GM','Spreeweg 8','71522','Backnang','marcguessmann@aol.com',NULL,' 071911873059',NULL,'0711951341200',NULL,NULL,'m',18,'Mo I,Sa A',NULL,'$2b$10$JJ3t80P.Km8FVtZKmiXpYuXEnzGSHLrvln3CGsCGidzJ2BKFMElLy',0,'guide, key'),(22,'Recknagel','Malin','MR','Stöcklestr. 36','72070','Tübingen ','malin.recknagel@freenet.de',NULL,NULL,'015776638250',NULL,NULL,NULL,'w',9,'','hat sich aus der Gruppe abgemeldet',NULL,0,'deleted'),(23,'Schuler','Bernd','SC','','','','bkschuler@gmail.com',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mo I,Sa C',NULL,NULL,0,'guide'),(24,'Riedl','Christoph','RC','Schillerstr. 30','73773','Aichwald','wp.riedl@my-steuerberatung.com',NULL,'',NULL,'0711 4116772','0711 4116773',NULL,'m',NULL,NULL,NULL,NULL,0,NULL),(25,'Nastos','Xeno','XN','','','','XNastos@t-online.de',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mi II,Sa B',NULL,'$2b$10$wkwvzkZqhLJ8He2jXW9Ttu88I61f3JbghsRRIM0Dm7guxKRIY7EBC',0,'guide'),(26,'Bernhard','Ralf','RB','Alpenrosenstr. 22','70563','Stuttgart','ralf_bernhard@web.de',NULL,'0711 4204151',NULL,NULL,NULL,NULL,'m',11,'Mo II,Sa A',NULL,'$2b$10$n/YFHBS1EJoZP8W1JwT9XOKH9bW6V.UvGNOBHDtKHa/Bq8krtTBla',0,'guide, key');
/*!40000 ALTER TABLE `beos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `objekte`
--
DROP TABLE IF EXISTS `objekte`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `objekte` (
`ID` int NOT NULL AUTO_INCREMENT,
`Name` varchar(200) NOT NULL,
`LastUsed` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`),
UNIQUE KEY `Name` (`Name`)
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `objekte`
--
LOCK TABLES `objekte` WRITE;
/*!40000 ALTER TABLE `objekte` DISABLE KEYS */;
INSERT INTO `objekte` VALUES (1,'Mond','2026-05-03 19:14:28','2026-04-27 15:44:00'),(2,'Jupiter','2026-05-03 19:27:25','2026-04-27 15:44:00'),(3,'M57','2026-05-03 16:40:23','2026-04-27 15:44:00'),(4,'Abend Stern','2026-04-28 11:35:55','2026-04-27 15:56:24'),(5,'eps Lyr','2026-04-27 18:34:44','2026-04-27 18:17:16'),(6,'beta Cyg','2026-04-28 11:39:17','2026-04-27 18:34:44'),(7,'M31','2026-05-03 16:47:00','2026-04-28 11:39:17'),(8,'M45','2026-05-03 19:27:25','2026-04-29 07:59:50'),(9,'Saturn','2026-05-02 08:07:36','2026-04-29 08:06:16'),(10,'M42','2026-05-03 19:27:25','2026-04-29 08:06:16'),(11,'alp Ori','2026-04-29 08:06:16','2026-04-29 08:06:16'),(12,'alpha Ori','2026-04-29 08:07:16','2026-04-29 08:07:16'),(13,'M44','2026-05-02 07:21:11','2026-05-01 07:29:14'),(14,'M82','2026-05-03 19:27:25','2026-05-01 07:39:27'),(15,'gam Leo','2026-05-03 19:19:41','2026-05-01 07:46:06'),(16,'chi+kap Per','2026-05-02 08:07:36','2026-05-01 07:46:06'),(17,'Sternbilder','2026-05-03 19:27:25','2026-05-01 07:46:06'),(18,'Uranus','2026-05-02 08:01:04','2026-05-02 07:57:26'),(19,'M81','2026-05-03 19:27:25','2026-05-02 07:57:26'),(20,'M37','2026-05-02 08:01:04','2026-05-02 08:01:04'),(21,'ISS','2026-05-02 08:01:04','2026-05-02 08:01:04'),(22,'NGC2392','2026-05-03 19:19:41','2026-05-03 16:34:25'),(23,'M1','2026-05-03 16:40:23','2026-05-03 16:34:25'),(24,'Mars','2026-05-03 16:40:23','2026-05-03 16:34:25'),(25,'Neptun','2026-05-03 19:14:28','2026-05-03 19:14:28'),(26,'Venus','2026-05-03 19:14:28','2026-05-03 19:14:28'),(27,'M97','2026-05-03 19:19:41','2026-05-03 19:16:32'),(28,'M3','2026-05-03 19:19:41','2026-05-03 19:16:32'),(29,'M13','2026-05-03 19:27:25','2026-05-03 19:25:42'),(30,'M35','2026-05-03 19:27:25','2026-05-03 19:25:42');
/*!40000 ALTER TABLE `objekte` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `logbuch`
--
DROP TABLE IF EXISTS `logbuch`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `logbuch` (
`ID` int NOT NULL AUTO_INCREMENT,
`Kuppel` enum('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
`ArtFuehrung` enum('RF','SF','PrF','BEOS','SonF','TD','Beob','ToT','Sonst') NOT NULL DEFAULT 'RF',
`Beginn` datetime NOT NULL,
`Ende` datetime NOT NULL,
`Besucher` int DEFAULT '0',
`Bemerkungen` text,
`WetterTemp` decimal(5,1) DEFAULT NULL,
`WetterFeuchte` decimal(5,1) DEFAULT NULL,
`WetterDruck` decimal(7,1) DEFAULT NULL,
`created_by` int DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`SonderName` varchar(200) DEFAULT NULL,
PRIMARY KEY (`ID`),
KEY `created_by` (`created_by`),
CONSTRAINT `logbuch_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `beos` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=49 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `logbuch`
--
LOCK TABLES `logbuch` WRITE;
/*!40000 ALTER TABLE `logbuch` DISABLE KEYS */;
INSERT INTO `logbuch` VALUES (16,'West','RF','2026-03-02 19:45:00','2026-03-02 21:45:00',25,NULL,7.0,85.0,1024.0,7,'2026-05-01 07:24:33',NULL),(17,'West','BEOS','2026-03-03 20:00:00','2026-03-03 21:30:00',0,NULL,0.0,0.0,0.0,18,'2026-05-01 07:26:45',NULL),(18,'West','RF','2026-03-04 19:45:00','2026-03-04 21:40:00',15,NULL,7.0,71.0,71.0,15,'2026-05-01 07:29:14',NULL),(19,'West','TD','2026-03-07 14:00:00','2026-03-07 17:00:00',0,'Techniktag - WK\n- Rolladenkasten gereinigt\n- USB- und LAN-Kabel für die Montierung eingezogen\n- Umalufzwischenraum gereinigt\n',16.0,43.0,1022.0,8,'2026-05-01 07:33:38',NULL),(20,'West','RF','2026-03-07 20:00:00','2026-03-07 22:00:00',21,NULL,8.0,66.0,1023.0,26,'2026-05-01 07:39:27',NULL),(21,'West','SF','2026-03-08 19:00:00','2026-03-08 21:00:00',4,NULL,10.0,68.0,1023.0,2,'2026-05-01 07:46:06',NULL),(23,'West','Beob','2026-03-12 22:30:00','2026-03-12 23:20:00',0,'Aufnahme Jupiter',6.0,77.0,1020.0,25,'2026-05-01 08:57:31',NULL),(26,'West','SF','2026-03-17 20:00:00','2026-03-17 21:30:00',25,'Vortrag und Einrichtung gezeigt',5.0,90.0,1023.0,8,'2026-05-02 07:48:27','Landfrauen'),(27,'West','RF','2026-03-18 19:30:00','2026-03-18 22:10:00',24,NULL,7.0,77.0,1023.0,15,'2026-05-02 07:57:26',NULL),(28,'West','SF','2026-03-19 19:30:00','2026-03-19 22:00:00',9,NULL,6.0,61.0,1021.0,2,'2026-05-02 08:07:36','Rene Leucht'),(32,'West','SF','2026-03-22 19:30:00','2026-03-22 22:20:00',4,NULL,6.0,64.0,1014.0,2,'2026-05-03 16:34:25','Kroboth'),(33,'West','TD','2026-03-23 19:30:00','2026-03-23 21:50:00',0,'Gespräch mit Installateuren der Fa. Günther für Splitgeräte, Spendenkasse und Schlüssel mitgenommen',9.0,69.0,1018.0,18,'2026-05-03 16:37:01',NULL),(34,'West','TD','2026-03-24 13:00:00','2026-03-24 14:00:00',0,'Lichtprojekt mit Fa. Helmer und Wölftl (Stadt. Welzheim)',16.0,49.0,1020.0,18,'2026-05-03 16:38:54',NULL),(35,'West','TD','2026-03-25 18:00:00','2026-03-25 19:00:00',0,'Kühlschrank und Drucker gekennzeichnet. Div. Aufräumarbeiten',2.0,92.0,1006.0,18,'2026-05-03 16:42:51',NULL),(36,'West','TD','2026-03-27 10:00:00','2026-03-27 12:00:00',0,'Splitgeräte ans WLAN. Account eingerichtet. Allsky lässt sich nicht betreiben, hängt.NAS in der OK funktioniert nicht. Unterstützung durch die Herren Wölfl und Scheibner',1.0,78.0,1024.0,18,'2026-05-03 16:45:05',NULL),(37,'West','RF','2026-03-23 19:30:00','2026-03-23 21:50:00',4,NULL,8.0,51.0,1022.0,2,'2026-05-03 16:47:00',NULL),(38,'West','Sonst','2026-03-28 15:30:00','2026-03-28 15:40:00',0,'Reinigungsmittel zur Sternwarte gebracht',0.0,0.0,0.0,8,'2026-05-03 16:48:32',NULL),(39,'West','ToT','2026-03-28 15:45:00','2026-03-28 17:00:00',0,NULL,1.0,92.0,1020.0,11,'2026-05-03 16:50:38',NULL),(40,'West','TD','2026-03-29 14:30:00','2026-03-29 16:00:00',1,'Inspektion',7.0,60.0,1026.0,17,'2026-05-03 18:23:39',NULL),(41,'West','TD','2026-03-29 16:30:00','2026-03-29 18:00:00',0,'NAS wieder gestarte, allg. Inspektion. Besprechung HUK Büro. Wetterstaion abgebaut. AllSky wieder funktionfähig gemcht, Spendekasse an e.Schneider übergebn',0.0,0.0,0.0,7,'2026-05-03 18:30:14',NULL),(43,'West','TD','2026-04-01 09:30:00','2026-04-01 12:30:00',0,'Weitere BEO: Sh (Silvia Schidhuber) WK-Steuerraum und Seminarraum gereinigt und ausgemistet',22.1,0.0,1010.0,2,'2026-05-03 18:35:47',NULL),(44,'West','RF','2026-03-01 21:00:00','2026-03-01 22:40:00',0,'Keine Besucher eingetragen !',22.2,0.0,1010.0,2,'2026-05-03 19:14:28',NULL),(45,'West','RF','2026-03-04 22:00:00','2026-03-04 23:35:00',2,NULL,22.2,0.0,1010.0,2,'2026-05-03 19:16:32',NULL),(46,'West','RF','2026-04-04 22:00:00','2026-04-04 23:35:00',2,NULL,10.1,0.0,994.0,2,'2026-05-03 19:19:41',NULL),(47,'West','SF','2026-04-07 21:00:00','2026-04-07 23:00:00',0,'keine Besucher eingetragen !',8.2,0.0,996.0,21,'2026-05-03 19:25:42','eigene Schüler'),(48,'West','RF','2026-04-08 21:00:00','2026-04-08 22:30:00',0,'Keine Besucher eingetragen',15.6,0.0,1014.0,15,'2026-05-03 19:27:04',NULL);
/*!40000 ALTER TABLE `logbuch` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `logbuch_beos`
--
DROP TABLE IF EXISTS `logbuch_beos`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `logbuch_beos` (
`ID` int NOT NULL AUTO_INCREMENT,
`LogbuchID` int NOT NULL,
`BeoID` int NOT NULL,
PRIMARY KEY (`ID`),
KEY `LogbuchID` (`LogbuchID`),
KEY `BeoID` (`BeoID`),
CONSTRAINT `logbuch_beos_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE,
CONSTRAINT `logbuch_beos_ibfk_2` FOREIGN KEY (`BeoID`) REFERENCES `beos` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=139 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `logbuch_beos`
--
LOCK TABLES `logbuch_beos` WRITE;
/*!40000 ALTER TABLE `logbuch_beos` DISABLE KEYS */;
INSERT INTO `logbuch_beos` VALUES (76,16,23),(77,16,7),(78,17,23),(79,17,24),(80,17,17),(81,17,18),(82,17,8),(83,17,9),(84,17,26),(85,17,25),(87,18,15),(88,18,25),(89,19,5),(90,19,8),(91,19,26),(92,19,25),(93,20,26),(94,20,25),(95,21,2),(96,23,25),(105,27,15),(106,27,9),(107,26,5),(108,26,8),(109,28,2),(115,34,18),(116,33,18),(117,32,2),(118,35,18),(119,36,18),(120,37,2),(122,39,11),(123,40,17),(124,41,7),(125,41,2),(126,41,18),(127,38,8),(129,43,2),(130,43,18),(131,44,2),(132,44,25),(133,45,2),(134,46,2),(136,48,15),(137,48,25),(138,47,21);
/*!40000 ALTER TABLE `logbuch_beos` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `logbuch_objekte`
--
DROP TABLE IF EXISTS `logbuch_objekte`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `logbuch_objekte` (
`ID` int NOT NULL AUTO_INCREMENT,
`LogbuchID` int NOT NULL,
`ObjektID` int DEFAULT NULL,
`ObjektName` varchar(200) NOT NULL,
PRIMARY KEY (`ID`),
KEY `LogbuchID` (`LogbuchID`),
KEY `ObjektID` (`ObjektID`),
CONSTRAINT `logbuch_objekte_ibfk_1` FOREIGN KEY (`LogbuchID`) REFERENCES `logbuch` (`ID`) ON DELETE CASCADE,
CONSTRAINT `logbuch_objekte_ibfk_2` FOREIGN KEY (`ObjektID`) REFERENCES `objekte` (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=175 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `logbuch_objekte`
--
LOCK TABLES `logbuch_objekte` WRITE;
/*!40000 ALTER TABLE `logbuch_objekte` DISABLE KEYS */;
INSERT INTO `logbuch_objekte` VALUES (55,16,2,'Jupiter'),(56,16,10,'M42'),(57,16,8,'M45'),(58,16,1,'Mond'),(59,18,2,'Jupiter'),(60,18,10,'M42'),(61,18,13,'M44'),(62,20,2,'Jupiter'),(63,20,10,'M42'),(64,20,8,'M45'),(65,20,14,'M82'),(66,21,16,'chi+kap Per'),(67,21,15,'gam Leo'),(68,21,2,'Jupiter'),(69,21,7,'M31'),(70,21,10,'M42'),(71,21,8,'M45'),(72,21,14,'M82'),(73,21,17,'Sternbilder'),(74,23,2,'Jupiter'),(85,27,2,'Jupiter'),(86,27,7,'M31'),(87,27,10,'M42'),(88,27,8,'M45'),(89,27,17,'Sternbilder'),(90,27,18,'Uranus'),(91,27,20,'M37'),(92,27,21,'ISS'),(93,28,15,'gam Leo'),(94,28,8,'M45'),(95,28,10,'M42'),(96,28,7,'M31'),(97,28,19,'M81'),(98,28,14,'M82'),(99,28,9,'Saturn'),(100,28,16,'chi+kap Per'),(101,28,17,'Sternbilder'),(112,32,15,'gam Leo'),(113,32,2,'Jupiter'),(114,32,23,'M1'),(115,32,10,'M42'),(116,32,8,'M45'),(117,32,3,'M57'),(118,32,24,'Mars'),(119,32,1,'Mond'),(120,32,22,'NGC2392'),(121,32,17,'Sternbilder'),(122,37,2,'Jupiter'),(123,37,1,'Mond'),(124,37,15,'gam Leo'),(125,37,8,'M45'),(126,37,10,'M42'),(127,37,7,'M31'),(128,37,19,'M81'),(129,37,14,'M82'),(130,37,17,'Sternbilder'),(131,44,8,'M45'),(132,44,10,'M42'),(133,44,25,'Neptun'),(134,44,2,'Jupiter'),(135,44,1,'Mond'),(136,44,26,'Venus'),(137,44,15,'gam Leo'),(138,45,2,'Jupiter'),(139,45,10,'M42'),(140,45,8,'M45'),(141,45,15,'gam Leo'),(142,45,19,'M81'),(143,45,14,'M82'),(144,45,22,'NGC2392'),(145,45,27,'M97'),(146,45,28,'M3'),(147,46,2,'Jupiter'),(148,46,10,'M42'),(149,46,8,'M45'),(150,46,28,'M3'),(151,46,15,'gam Leo'),(152,46,19,'M81'),(153,46,14,'M82'),(154,46,22,'NGC2392'),(155,46,27,'M97'),(164,48,10,'M42'),(165,48,2,'Jupiter'),(166,48,17,'Sternbilder'),(167,47,2,'Jupiter'),(168,47,29,'M13'),(169,47,30,'M35'),(170,47,10,'M42'),(171,47,8,'M45'),(172,47,19,'M81'),(173,47,14,'M82'),(174,47,17,'Sternbilder');
/*!40000 ALTER TABLE `logbuch_objekte` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-05-08 17:43:48
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Migriert die Datenbank von latin1 auf utf8mb4.
# Läuft auf dem Server im Verzeichnis der compose.yml.
# Voraussetzung: iconv installiert (apt install libc-bin)
set -e
CONTAINER="logbuch_mysql"
DB="sternwarte"
DUMP_LATIN1="/tmp/sternwarte_latin1.sql"
DUMP_UTF8="/tmp/sternwarte_utf8mb4.sql"
# Root-Passwort aus .env lesen
ROOT_PASS=$(grep DB_ROOT_PASS .env | cut -d= -f2)
if [ -z "$ROOT_PASS" ]; then
echo "FEHLER: DB_ROOT_PASS nicht in .env gefunden." >&2
exit 1
fi
echo "══════════════════════════════════════════════════════"
echo " latin1 → utf8mb4 Migration: $DB"
echo "══════════════════════════════════════════════════════"
echo ""
# ── Sicherheits-Backup ───────────────────────────────────────────────────────
BACKUP="/tmp/sternwarte_backup_$(date +%Y%m%d_%H%M%S).sql"
echo ">>> Erstelle Backup: $BACKUP"
docker exec "$CONTAINER" mysqldump \
-u root -p"$ROOT_PASS" \
--default-character-set=latin1 \
--single-transaction \
--no-tablespaces \
--set-gtid-purged=OFF \
"$DB" > "$BACKUP"
echo " Backup gespeichert: $BACKUP"
echo ""
# ── Dump als latin1 exportieren ──────────────────────────────────────────────
echo ">>> Exportiere Daten mit latin1-Zeichensatz..."
docker exec "$CONTAINER" mysqldump \
-u root -p"$ROOT_PASS" \
--default-character-set=latin1 \
--single-transaction \
--no-tablespaces \
--skip-set-charset \
--set-gtid-purged=OFF \
"$DB" > "$DUMP_LATIN1"
echo " Dump: $DUMP_LATIN1"
echo ""
# ── Bytes latin1 → utf8 konvertieren ────────────────────────────────────────
echo ">>> Konvertiere Bytes: latin1 → utf8..."
iconv -f latin1 -t utf8 "$DUMP_LATIN1" > "$DUMP_UTF8"
# Charset-Deklarationen im SQL ersetzen
sed -i \
-e 's/DEFAULT CHARSET=latin1/DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci/g' \
-e 's/COLLATE=latin1_swedish_ci//g' \
-e 's/CHARACTER SET latin1/CHARACTER SET utf8mb4/g' \
-e 's/COLLATE latin1_swedish_ci/COLLATE utf8mb4_unicode_ci/g' \
"$DUMP_UTF8"
echo " Konvertiert: $DUMP_UTF8"
echo ""
# ── Zeilenzähler vor Migration ───────────────────────────────────────────────
echo ">>> Zeilenzähler vor Migration:"
for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do
COUNT=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "$DB" 2>/dev/null || echo "n/a")
printf " %-25s %5s Zeilen\n" "$TABLE" "$COUNT"
done
echo ""
# ── Zieldatenbank anlegen ────────────────────────────────────────────────────
echo ">>> Lege Zieldatenbank an (utf8mb4)..."
docker exec -i "$CONTAINER" mysql -u root -p"$ROOT_PASS" <<EOF
DROP DATABASE IF EXISTS ${DB}_new;
CREATE DATABASE ${DB}_new CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
EOF
# ── utf8mb4-Dump importieren ─────────────────────────────────────────────────
echo ">>> Importiere utf8mb4-Daten..."
docker exec -i "$CONTAINER" mysql \
-u root -p"$ROOT_PASS" \
--default-character-set=utf8mb4 \
"${DB}_new" < "$DUMP_UTF8"
# ── Zeilenzähler nach Migration ──────────────────────────────────────────────
echo ""
echo ">>> Zeilenzähler nach Migration:"
ALL_OK=true
for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do
BEFORE=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "${DB}" 2>/dev/null || echo "?")
AFTER=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "${DB}_new" 2>/dev/null || echo "?")
STATUS="✓"
[ "$BEFORE" != "$AFTER" ] && STATUS="✗ ABWEICHUNG" && ALL_OK=false
printf " %-25s %5s → %5s %s\n" "$TABLE" "$BEFORE" "$AFTER" "$STATUS"
done
echo ""
if [ "$ALL_OK" = false ]; then
echo "FEHLER: Zeilenzähler stimmen nicht überein!" >&2
echo "Datenbank '${DB}_new' bleibt zur manuellen Prüfung erhalten." >&2
exit 1
fi
# ── Swap: sternwarte → sternwarte_old, sternwarte_new → sternwarte ───────────
echo ">>> Alle Zeilenzähler stimmen — tausche Datenbanken..."
TABLES=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SHOW TABLES;" "${DB}_new")
# Alte DB umbenennen (Tabellen nach sternwarte_old verschieben)
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"CREATE DATABASE IF NOT EXISTS ${DB}_old CHARACTER SET latin1;"
for TABLE in $TABLES; do
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"RENAME TABLE \`${DB}\`.\`$TABLE\` TO \`${DB}_old\`.\`$TABLE\`;"
done
# Neue DB nach sternwarte verschieben
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"ALTER DATABASE ${DB} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
for TABLE in $TABLES; do
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"RENAME TABLE \`${DB}_new\`.\`$TABLE\` TO \`${DB}\`.\`$TABLE\`;"
done
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"DROP DATABASE ${DB}_new; DROP DATABASE ${DB}_old;"
# ── Kollation bestätigen ─────────────────────────────────────────────────────
echo ""
echo ">>> Kollation der Tabellen:"
docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -e \
"SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '$DB' ORDER BY TABLE_NAME;" 2>/dev/null
echo ""
echo "══════════════════════════════════════════════════════"
echo " Migration erfolgreich abgeschlossen!"
echo " Backup: $BACKUP"
echo " App neu starten: docker compose up -d logbuch_app"
echo "══════════════════════════════════════════════════════"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.0.0", "version": "1.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Auf dem Server ausführen, nachdem docker-compose gestartet wurde.
# Importiert logbuch_dump.sql in den laufenden logbuch_mysql-Container.
set -e
CONTAINER="logbuch_mysql"
DB="sternwarte"
DUMPFILE="${1:-logbuch_dump.sql}"
if [ ! -f "$DUMPFILE" ]; then
echo "FEHLER: Dump-Datei '$DUMPFILE' nicht gefunden." >&2
exit 1
fi
# Root-Passwort aus .env.prod lesen
ROOT_PASS=$(grep DB_ROOT_PASS .env.prod | cut -d= -f2)
if [ -z "$ROOT_PASS" ]; then
echo "FEHLER: DB_ROOT_PASS nicht in .env.prod gefunden." >&2
exit 1
fi
echo "Importiere '$DUMPFILE' in Container '$CONTAINER'..."
docker exec -i "$CONTAINER" mysql -u root -p"$ROOT_PASS" "$DB" < "$DUMPFILE"
echo "Verifikation:"
for TABLE in beos objekte logbuch logbuch_beos logbuch_objekte; do
COUNT=$(docker exec "$CONTAINER" mysql -u root -p"$ROOT_PASS" -sN \
-e "SELECT COUNT(*) FROM $TABLE;" "$DB" 2>/dev/null)
printf " %-25s %5s Zeilen\n" "$TABLE" "$COUNT"
done
echo "Import abgeschlossen."
+13 -8
View File
@@ -4,19 +4,23 @@ export type ArtFuehrung = 'RF' | 'SF' | 'PrF' | 'BEOS' | 'SonF' | 'TD' | 'Beob'
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto']; export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
export const ARTEN_MAP: Record<ArtFuehrung, string> = { export const ARTEN_MAP: Record<ArtFuehrung, string> = {
RF: 'Reguläre Führung', RF: 'regulär',
SF: 'Sonderführung', SF: 'sonder',
PrF: 'Private Führung', SonF: 'sonnen',
BEOS: 'BEO-Sitzung', PrF: 'privat',
SonF: 'Sonnenführung', BEOS: '',
TD: 'Technischer Dienst', TD: '',
Beob: 'Beobachtung', Beob: 'Beobachtung',
ToT: 'Tag der offenen Tür', ToT: '',
Sonst:'Sonstiges', Sonst: 'Sonstiges',
}; };
export const ARTEN = Object.keys(ARTEN_MAP) as ArtFuehrung[]; export const ARTEN = Object.keys(ARTEN_MAP) as ArtFuehrung[];
export function artLabel(a: ArtFuehrung): string {
return ARTEN_MAP[a] || a;
}
export interface BeoOption { export interface BeoOption {
ID: number; ID: number;
Kuerzel: string; Kuerzel: string;
@@ -43,6 +47,7 @@ export interface LogbuchEintrag {
ID: number; ID: number;
Kuppel: Kuppel; Kuppel: Kuppel;
ArtFuehrung: ArtFuehrung; ArtFuehrung: ArtFuehrung;
SonderName: string | null;
Beginn: string; Beginn: string;
Ende: string; Ende: string;
Besucher: number; Besucher: number;