Compare commits
10 Commits
e4f3e7750a
...
64acfdda6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 64acfdda6f | |||
| a0fb6d8089 | |||
| 2469715756 | |||
| e3e5842805 | |||
| 23cd981f7f | |||
| 34a2c6b90d | |||
| aea5cc08d6 | |||
| e7f2266a35 | |||
| 5f92fd0d7f | |||
| 71f4ad1792 |
40
CLAUDE.md
Normal file
40
CLAUDE.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Development server
|
||||||
|
npm run build # Production build (run after every change to verify)
|
||||||
|
npm run lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
No test suite exists. Deploy via `./deploy.sh [tag]` — builds multiplatform Docker image (amd64 + arm64) and pushes to `docker.citysensor.de`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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}\``.
|
||||||
|
|
||||||
|
**API routes** (`app/api/`): all check `getSession()` and return 401 if unauthenticated. The logbuch list query uses `GROUP_CONCAT` to aggregate BEOs and Objekte into comma-separated strings per entry.
|
||||||
|
|
||||||
|
## Key components
|
||||||
|
|
||||||
|
- **`CustomSelect`**: replaces native `<select>` everywhere — iOS/Android native popups ignore CSS sizing. Supports `keepOpen` prop for multi-select use cases (BEOs, Objekte).
|
||||||
|
- **`TimePicker5`**: custom time picker, no native `<input type="time">`. Shows HH:MM with ▲/▼ buttons, 5-minute steps, auto-repeat on hold (400 ms delay → 1-hour steps at 350 ms). Keyboard: ↑/↓.
|
||||||
|
- **`LogbuchForm`**: Beginn/Ende stored as `"YYYY-MM-DDTHH:MM"` strings. Date and time are split into separate `<input type="date">` + `<TimePicker5>`. Beginn date change syncs Ende date automatically.
|
||||||
|
- **`LogbuchList`**: accepts `compact` and `limit` props. Compact mode used for the 5-entry preview below the form on desktop (`hidden lg:block`).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`ArtFuehrung` is stored as abbreviations in the DB (`RF`, `SF`, `PrF`, `BEOS`, `SonF`, `TD`, `Beob`, `ToT`, `Sonst`). Display names are in `ARTEN_MAP` in `types/logbuch.ts`. `BEOS` and `TD` hide the Besucher and Objekte fields. `SonF` pre-selects "Sonne" as the only object.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
`output: 'standalone'` is set in `next.config.ts` for Docker. The MySQL container name in production is `db` — set `DB_HOST=db` in the server's environment.
|
||||||
@@ -29,12 +29,12 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
|||||||
function handleSaved() {
|
function handleSaved() {
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
setEditEntry(null);
|
setEditEntry(null);
|
||||||
if (editEntry) setActiveTab('liste');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(entry: LogbuchEintrag) {
|
function handleEdit(entry: LogbuchEintrag) {
|
||||||
setEditEntry(entry);
|
setEditEntry(entry);
|
||||||
setActiveTab('eingabe');
|
setActiveTab('eingabe');
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@@ -48,14 +48,11 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
|||||||
|
|
||||||
{/* 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-4 gap-2">
|
||||||
<h1 className="text-xl sm:text-3xl font-bold leading-tight">
|
<h1 className="text-xl sm:text-2xl font-bold leading-tight">
|
||||||
Logbuch<span className="hidden sm:inline"> — Sternwarte Welzheim</span>
|
<span className="hidden sm:inline">Sternwarte-Welzheim </span>
|
||||||
|
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">
|
||||||
<span className="text-xs sm:text-sm text-gray-600 hidden sm:inline">
|
|
||||||
{kuerzel} — {beoName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-600 sm:hidden">{kuerzel}</span>
|
|
||||||
<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-gray-200 hover:bg-gray-300 rounded-lg text-gray-700"
|
||||||
@@ -78,17 +75,17 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="sm:hidden">{k}</span>
|
<span className="sm:hidden">{k}</span>
|
||||||
<span className="hidden sm:inline">Kuppel {k}</span>
|
<span className="hidden sm:inline">{k}-Kuppel</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Eingabe/Liste-Tabs */}
|
{/* Eingabe/Liste-Tabs */}
|
||||||
<div className="flex gap-1 mb-5 border-b border-gray-200">
|
<div className="flex gap-1 mb-4 border-b border-gray-200">
|
||||||
{(['eingabe', 'liste'] as const).map((tab) => (
|
{(['eingabe', 'liste'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => { setActiveTab(tab); if (tab === 'eingabe' && editEntry) setEditEntry(null); }}
|
onClick={() => { setActiveTab(tab); if (tab === 'eingabe') setEditEntry(null); }}
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'border-[#85B7D7] text-gray-900'
|
? 'border-[#85B7D7] text-gray-900'
|
||||||
@@ -100,10 +97,11 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Eingabe-Tab: Formular + kompakte Vorschau-Liste (nur Desktop) */}
|
||||||
{activeTab === 'eingabe' && (
|
{activeTab === 'eingabe' && (
|
||||||
<div>
|
<>
|
||||||
{editEntry && (
|
{editEntry && (
|
||||||
<div className="mb-4 text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2">
|
<div className="mb-3 text-sm text-amber-700 bg-amber-50 border border-amber-300 rounded-lg px-3 py-2">
|
||||||
Eintrag bearbeiten (ID {editEntry.ID})
|
Eintrag bearbeiten (ID {editEntry.ID})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -114,18 +112,40 @@ export default function MainClient({ kuerzel, beoId, beoName }: Props) {
|
|||||||
editEntry={editEntry}
|
editEntry={editEntry}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
{/* Kompakte Liste — nur auf Desktop sichtbar */}
|
||||||
|
<div className="hidden lg:block mt-5 border-t-2 border-gray-300 pt-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-600">Letzte Einträge</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('liste')}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Alle anzeigen →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LogbuchList
|
||||||
|
kuppel={activeKuppel}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
limit={5}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Liste-Tab: vollständige Liste */}
|
||||||
{activeTab === 'liste' && (
|
{activeTab === 'liste' && (
|
||||||
<LogbuchList
|
<LogbuchList
|
||||||
kuppel={activeKuppel}
|
kuppel={activeKuppel}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
limit={20}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mt-8 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4">
|
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4">
|
||||||
<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
|
||||||
|
|||||||
@@ -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(
|
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 ORDER BY name ASC'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
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 });
|
||||||
const temp = Math.round((8 + Math.random() * 15) * 10) / 10;
|
const temp = Math.round((8 + Math.random() * 15) * 10) / 10;
|
||||||
const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10;
|
const feuchte = Math.round((40 + Math.random() * 50) * 10) / 10;
|
||||||
const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
|
const druck = Math.round((990 + Math.random() * 30) * 10) / 10;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -21,15 +21,17 @@ export async function login(
|
|||||||
return { error: 'Ungültiges Kürzel oder Passwort.' };
|
return { error: 'Ungültiges Kürzel oder Passwort.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mustChange = result.beo.MustChangePassword === 1 || !result.beo.pw;
|
||||||
|
|
||||||
await createSession({
|
await createSession({
|
||||||
kuerzel: result.beo.kürzel ?? kuerzel,
|
kuerzel: result.beo.kürzel ?? kuerzel,
|
||||||
beoId: result.beo.id,
|
beoId: result.beo.id,
|
||||||
beoName: getBeoDisplayName(result.beo),
|
beoName: getBeoDisplayName(result.beo),
|
||||||
mustChangePassword: result.beo.MustChangePassword === 1,
|
mustChangePassword: mustChange,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.beo.MustChangePassword === 1 || !result.beo.pw) {
|
if (mustChange) {
|
||||||
redirect('/change-password');
|
redirect('/change-password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Bei den unterschiedlichen Führungen solle einige Einträge wegfallen:
|
Bei den unterschiedlichen Führungen solle einige Einträge wegfallen:
|
||||||
* bei Sonnenführung immer die Sonne als Objekt vorgeben, keine Auswahl von anderen Objekten zulassen
|
* bei Sonnenführung immer die Sonne als Objekt vorgeben, keine Auswahl von anderen Objekten zulassen
|
||||||
* bei BEO-Sitzung und bei Technischer Dienst kein Objekte-Feld vorsehen (in der DB bleibt das Feld leer)
|
* bei BEO-Sitzung und bei Technischer Dienst kein Objekte-Feld vorsehen (in der DB bleibt das Feld leer)
|
||||||
|
* bei BEO-Sitzung und bei Technischer Dienst kein Besucherfeld
|
||||||
|
|
||||||
### Responsives Design
|
### Responsives Design
|
||||||
Passe das Design des Eingabefensters so an, dass es auf eimem Smartphone-Bildschirm gut zu bedienen ist
|
Passe das Design des Eingabefensters so an, dass es auf eimem Smartphone-Bildschirm gut zu bedienen ist
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedIds = new Set(selected.map((b) => b.ID));
|
const selectedIds = new Set(selected.map((b) => b.ID));
|
||||||
const available = all.filter((b) => !selectedIds.has(b.ID));
|
const available = all
|
||||||
|
.filter((b) => !selectedIds.has(b.ID))
|
||||||
|
.sort((a, b) => a.Kuerzel.localeCompare(b.Kuerzel));
|
||||||
|
|
||||||
function add(value: string) {
|
function add(value: string) {
|
||||||
const beo = all.find((b) => b.ID === parseInt(value));
|
const beo = all.find((b) => b.ID === parseInt(value));
|
||||||
@@ -37,9 +39,9 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
{selected.map((b) => (
|
{selected.map((b) => (
|
||||||
<span
|
<span
|
||||||
key={b.ID}
|
key={b.ID}
|
||||||
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-2 rounded-full"
|
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full"
|
||||||
>
|
>
|
||||||
{b.Kuerzel} — {b.Name}
|
{b.Kuerzel}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove(b.ID)}
|
onClick={() => remove(b.ID)}
|
||||||
@@ -54,9 +56,10 @@ export default function BeoSelector({ selected, onChange }: Props) {
|
|||||||
|
|
||||||
{available.length > 0 && (
|
{available.length > 0 && (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
placeholder="+ BEO hinzufügen"
|
placeholder="+ BEOs hinzufügen"
|
||||||
options={available.map((b) => ({ value: String(b.ID), label: `${b.Kuerzel} — ${b.Name}` }))}
|
options={available.map((b) => ({ value: String(b.ID), label: `${b.Kuerzel} — ${b.Name}` }))}
|
||||||
onChange={add}
|
onChange={add}
|
||||||
|
keepOpen
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface Props {
|
|||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
keepOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomSelect({ options, placeholder, onChange }: Props) {
|
export default function CustomSelect({ options, placeholder, onChange, keepOpen = false }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export default function CustomSelect({ options, placeholder, onChange }: Props)
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
function select(value: string) {
|
function select(value: string) {
|
||||||
setOpen(false);
|
if (!keepOpen) setOpen(false);
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export default function CustomSelect({ options, placeholder, onChange }: Props)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="w-full flex items-center justify-between px-4 py-3 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-4 py-2 border-2 border-gray-400 rounded-lg bg-white text-base text-gray-900 focus:border-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<span>{placeholder}</span>
|
<span>{placeholder}</span>
|
||||||
<svg
|
<svg
|
||||||
@@ -57,11 +58,20 @@ export default function CustomSelect({ options, placeholder, onChange }: Props)
|
|||||||
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-3 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>
|
||||||
))}
|
))}
|
||||||
|
{keepOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="w-full px-4 py-2 text-base font-medium text-center bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-b-lg border-t-2 border-gray-300"
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, 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 } from '@/types/logbuch';
|
import { ARTEN, ARTEN_MAP } 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';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
kuppel: Kuppel;
|
kuppel: Kuppel;
|
||||||
@@ -19,20 +20,49 @@ 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');
|
||||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
const raw = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||||
|
return snapTo5(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEO-Sitzung', 'Technischer Dienst'];
|
const NO_OBJEKTE_ARTEN: ArtFuehrung[] = ['BEOS', 'TD'];
|
||||||
const SONNE_ART: ArtFuehrung = 'Sonnenführung';
|
const SONNE_ART: ArtFuehrung = 'SonF';
|
||||||
|
|
||||||
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
|
export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved }: Props) {
|
||||||
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('Reguläre Führung');
|
const [artFuehrung, setArtFuehrung] = useState<ArtFuehrung>('RF');
|
||||||
const [beginn, setBeginn] = useState(nowLocalDatetime());
|
const [beginn, setBeginn] = useState(nowLocalDatetime());
|
||||||
const [ende, setEnde] = useState(nowLocalDatetime());
|
const [ende, setEnde] = useState(nowLocalDatetime());
|
||||||
const [besucher, setBesucher] = useState(0);
|
const [besucher, setBesucher] = useState<number | ''>('');
|
||||||
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
|
const [beos, setBeos] = useState<BeoOption[]>([currentUserBeo]);
|
||||||
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
|
const [objekte, setObjekte] = useState<SelectedObjekt[]>([]);
|
||||||
const [bemerkungen, setBemerkungen] = useState('');
|
const [bemerkungen, setBemerkungen] = useState('');
|
||||||
@@ -42,6 +72,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
const showObjekte = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
||||||
|
const showBesucher = !NO_OBJEKTE_ARTEN.includes(artFuehrung);
|
||||||
const isSonne = artFuehrung === SONNE_ART;
|
const isSonne = artFuehrung === SONNE_ART;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,7 +87,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
setArtFuehrung(editEntry.ArtFuehrung);
|
setArtFuehrung(editEntry.ArtFuehrung);
|
||||||
setBeginn(toLocalDatetimeValue(editEntry.Beginn));
|
setBeginn(toLocalDatetimeValue(editEntry.Beginn));
|
||||||
setEnde(toLocalDatetimeValue(editEntry.Ende));
|
setEnde(toLocalDatetimeValue(editEntry.Ende));
|
||||||
setBesucher(editEntry.Besucher);
|
setBesucher(editEntry.Besucher ?? '');
|
||||||
setBemerkungen(editEntry.Bemerkungen ?? '');
|
setBemerkungen(editEntry.Bemerkungen ?? '');
|
||||||
if (editEntry.WetterTemp !== null) {
|
if (editEntry.WetterTemp !== null) {
|
||||||
setWetter({
|
setWetter({
|
||||||
@@ -66,13 +97,14 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setArtFuehrung('Reguläre Führung');
|
setArtFuehrung('RF');
|
||||||
setBeginn(nowLocalDatetime());
|
setBeginn(nowLocalDatetime());
|
||||||
setEnde(nowLocalDatetime());
|
setEnde(nowLocalDatetime());
|
||||||
setBesucher(0);
|
setBesucher(0);
|
||||||
setBeos([currentUserBeo]);
|
setBeos([currentUserBeo]);
|
||||||
setObjekte([]);
|
setObjekte([]);
|
||||||
setBemerkungen('');
|
setBemerkungen('');
|
||||||
|
setBesucher('');
|
||||||
}
|
}
|
||||||
}, [editEntry, currentUserBeo]);
|
}, [editEntry, currentUserBeo]);
|
||||||
|
|
||||||
@@ -121,7 +153,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
ArtFuehrung: artFuehrung,
|
ArtFuehrung: artFuehrung,
|
||||||
Beginn: beginn,
|
Beginn: beginn,
|
||||||
Ende: ende,
|
Ende: ende,
|
||||||
Besucher: besucher,
|
Besucher: besucher === '' ? 0 : besucher,
|
||||||
beoIds: beos.map((b) => b.ID),
|
beoIds: beos.map((b) => b.ID),
|
||||||
objekte: showObjekte ? objekte : [],
|
objekte: showObjekte ? objekte : [],
|
||||||
Bemerkungen: bemerkungen,
|
Bemerkungen: bemerkungen,
|
||||||
@@ -139,6 +171,7 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 5000);
|
||||||
if (!editEntry) {
|
if (!editEntry) {
|
||||||
setBeginn(nowLocalDatetime());
|
setBeginn(nowLocalDatetime());
|
||||||
setEnde(nowLocalDatetime());
|
setEnde(nowLocalDatetime());
|
||||||
@@ -155,57 +188,78 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full px-3 py-3 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none';
|
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 labelCls = 'block text-sm font-medium text-gray-700 mb-1';
|
const labelCls = 'block text-sm font-medium text-gray-700 mb-0.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 max-w-2xl">
|
<form onSubmit={handleSubmit} className="space-y-3 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-4 bg-white">
|
||||||
|
|
||||||
{/* Art der Führung — volle Breite */}
|
{/* Art der Führung — volle Breite */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>Art der Führung</label>
|
<label className={labelCls}>Art der Führung</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
placeholder={artFuehrung}
|
placeholder={`${artFuehrung} — ${ARTEN_MAP[artFuehrung]}`}
|
||||||
options={ARTEN.map((a) => ({ value: a, label: a }))}
|
options={ARTEN.map((a) => ({ value: a, label: `${a} — ${ARTEN_MAP[a]}` }))}
|
||||||
onChange={(v) => setArtFuehrung(v as ArtFuehrung)}
|
onChange={(v) => setArtFuehrung(v as ArtFuehrung)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Beginn / Ende — nebeneinander auf Desktop, untereinander auf Mobile */}
|
{/* Beginn / Ende / Besucher */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||||
<div>
|
<div className="w-full sm:flex-1">
|
||||||
<label className={labelCls}>Beginn</label>
|
<label className={labelCls}>Beginn</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="datetime-local"
|
<input
|
||||||
value={beginn}
|
type="date"
|
||||||
onChange={(e) => setBeginn(e.target.value)}
|
value={beginn.slice(0, 10)}
|
||||||
required
|
onChange={(e) => {
|
||||||
className={inputCls}
|
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 text-gray-900 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>
|
<div className="w-full sm:flex-1">
|
||||||
<label className={labelCls}>Ende</label>
|
<label className={labelCls}>Ende</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="datetime-local"
|
<input
|
||||||
value={ende}
|
type="date"
|
||||||
onChange={(e) => setEnde(e.target.value)}
|
value={ende.slice(0, 10)}
|
||||||
required
|
onChange={(e) => {
|
||||||
className={inputCls}
|
if (!e.target.value) return;
|
||||||
/>
|
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 text-gray-900 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<TimePicker5
|
||||||
|
value={ende.slice(11, 16)}
|
||||||
|
onChange={(t) => setEnde(ende.slice(0, 10) + 'T' + t)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{showBesucher && (
|
||||||
|
<div className="sm:shrink-0">
|
||||||
{/* Besucher */}
|
<label className={labelCls}>Besucher</label>
|
||||||
<div>
|
<input
|
||||||
<label className={labelCls}>Anzahl Besucher</label>
|
type="number"
|
||||||
<input
|
value={besucher}
|
||||||
type="number"
|
onChange={(e) => setBesucher(e.target.value === '' ? '' : parseInt(e.target.value) || 0)}
|
||||||
value={besucher}
|
min={0}
|
||||||
onChange={(e) => setBesucher(parseInt(e.target.value) || 0)}
|
max={9999}
|
||||||
min={0}
|
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"
|
||||||
max={9999}
|
/>
|
||||||
className="w-32 px-3 py-3 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none"
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BEOs */}
|
{/* BEOs */}
|
||||||
@@ -240,8 +294,8 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
<textarea
|
<textarea
|
||||||
value={bemerkungen}
|
value={bemerkungen}
|
||||||
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
|
onChange={(e) => setBemerkungen(e.target.value.slice(0, 500))}
|
||||||
rows={3}
|
rows={2}
|
||||||
className="w-full px-3 py-3 border-2 border-gray-400 rounded-lg bg-white text-base focus:border-blue-500 focus:outline-none resize-y"
|
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"
|
||||||
placeholder="Freier Text (max. 500 Zeichen)"
|
placeholder="Freier Text (max. 500 Zeichen)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,12 +323,12 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Buttons — volle Breite auf Mobile */}
|
{/* Buttons — zentriert */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full sm:w-auto px-6 py-3 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-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-base"
|
||||||
>
|
>
|
||||||
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
|
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
|
||||||
</button>
|
</button>
|
||||||
@@ -282,7 +336,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-3 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-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-base"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ interface Props {
|
|||||||
kuppel: Kuppel;
|
kuppel: Kuppel;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onEdit: (entry: LogbuchEintrag) => void;
|
onEdit: (entry: LogbuchEintrag) => void;
|
||||||
|
limit?: number;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(dt: string): string {
|
function formatDateTime(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) {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
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' });
|
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 }: Props) {
|
export default function LogbuchList({ kuppel, refreshKey, onEdit, limit = 20, compact = false }: Props) {
|
||||||
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
|
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
@@ -24,11 +30,11 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=20`)
|
fetch(`/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}`)
|
||||||
.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); setLoading(false); })
|
||||||
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
||||||
}, [kuppel, refreshKey]);
|
}, [kuppel, refreshKey, limit]);
|
||||||
|
|
||||||
async function confirmDelete(id: number) {
|
async function confirmDelete(id: number) {
|
||||||
try {
|
try {
|
||||||
@@ -46,46 +52,55 @@ export default function LogbuchList({ kuppel, refreshKey, onEdit }: Props) {
|
|||||||
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
|
if (error) return <div className="text-red-600 text-sm py-4">{error}</div>;
|
||||||
if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>;
|
if (entries.length === 0) return <div className="text-gray-500 text-sm py-4">Keine Einträge vorhanden.</div>;
|
||||||
|
|
||||||
|
const cell = compact
|
||||||
|
? 'px-1.5 py-1 border border-gray-200 text-xs'
|
||||||
|
: 'px-3 py-2 border border-gray-200';
|
||||||
|
const head = compact
|
||||||
|
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
|
||||||
|
: 'px-3 py-2 border border-gray-300';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm border-collapse">
|
<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="px-3 py-2 border border-gray-300 whitespace-nowrap">Beginn</th>
|
<th className={`${head} whitespace-nowrap`}>Beginn</th>
|
||||||
<th className="px-3 py-2 border border-gray-300 whitespace-nowrap">Ende</th>
|
<th className={`${head} whitespace-nowrap`}>Ende</th>
|
||||||
<th className="px-3 py-2 border border-gray-300">Art</th>
|
<th className={head}>Art</th>
|
||||||
<th className="px-3 py-2 border border-gray-300 text-center">Besucher</th>
|
<th className={`${head} text-center`}>Besucher</th>
|
||||||
<th className="px-3 py-2 border border-gray-300">BEOs</th>
|
<th className={head}>BEOs</th>
|
||||||
<th className="px-3 py-2 border border-gray-300">Objekte</th>
|
<th className={head}>Objekte</th>
|
||||||
<th className="px-3 py-2 border border-gray-300">Bemerkungen</th>
|
{!compact && <th className={head}>Bemerkungen</th>}
|
||||||
<th className="px-3 py-2 border border-gray-300 text-center">Aktionen</th>
|
<th className={`${head} text-center`}>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.ID} className="hover:bg-gray-50">
|
<tr key={e.ID} className="hover:bg-gray-50">
|
||||||
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Beginn)}</td>
|
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Beginn, compact)}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200 whitespace-nowrap">{formatDateTime(e.Ende)}</td>
|
<td className={`${cell} whitespace-nowrap`}>{formatDateTime(e.Ende, compact)}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200">{e.ArtFuehrung}</td>
|
<td className={cell}>{e.ArtFuehrung}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200 text-center">{e.Besucher}</td>
|
<td className={`${cell} text-center`}>{e.Besucher}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200">{e.BEOs || '—'}</td>
|
<td className={cell}>{e.BEOs || '—'}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200">{e.Objekte || '—'}</td>
|
<td className={cell}>{e.Objekte || '—'}</td>
|
||||||
<td className="px-3 py-2 border border-gray-200 max-w-xs">
|
{!compact && (
|
||||||
<span className="line-clamp-2">{e.Bemerkungen || ''}</span>
|
<td className={`${cell} max-w-xs`}>
|
||||||
</td>
|
<span className="line-clamp-2">{e.Bemerkungen || ''}</span>
|
||||||
<td className="px-3 py-2 border border-gray-200 text-center whitespace-nowrap">
|
</td>
|
||||||
|
)}
|
||||||
|
<td className={`${cell} text-center whitespace-nowrap`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(e)}
|
onClick={() => onEdit(e)}
|
||||||
className="text-blue-600 hover:text-blue-800 mr-3 text-xs font-medium"
|
className="text-blue-600 hover:text-blue-800 mr-2 font-medium"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
✎
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteId(e.ID)}
|
onClick={() => setDeleteId(e.ID)}
|
||||||
className="text-red-600 hover:text-red-800 text-xs font-medium"
|
className="text-red-600 hover:text-red-800 font-medium"
|
||||||
>
|
>
|
||||||
Löschen
|
✕
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
|
const available = all.filter((o) => !selectedNames.has(o.Name.toLowerCase()));
|
||||||
|
|
||||||
function add(value: string) {
|
function add(value: string) {
|
||||||
if (value === 'neu') {
|
|
||||||
setShowNewInput(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const obj = all.find((o) => o.ID === parseInt(value));
|
const obj = all.find((o) => o.ID === parseInt(value));
|
||||||
if (obj && !selectedNames.has(obj.Name.toLowerCase())) {
|
if (obj && !selectedNames.has(obj.Name.toLowerCase())) {
|
||||||
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
||||||
@@ -47,18 +43,13 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
onChange(selected.filter((o) => o.Name !== name));
|
onChange(selected.filter((o) => o.Name !== name));
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: 'neu', label: '— Neues Objekt eingeben —' },
|
|
||||||
...available.map((o) => ({ value: String(o.ID), label: o.Name })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selected.map((o) => (
|
{selected.map((o) => (
|
||||||
<span
|
<span
|
||||||
key={o.Name}
|
key={o.Name}
|
||||||
className="inline-flex items-center gap-2 bg-green-100 text-green-800 text-base px-3 py-2 rounded-full"
|
className="inline-flex items-center gap-2 bg-green-100 text-green-800 text-base px-3 py-1.5 rounded-full"
|
||||||
>
|
>
|
||||||
{o.Name}
|
{o.Name}
|
||||||
<button
|
<button
|
||||||
@@ -73,11 +64,25 @@ export default function ObjektSelector({ selected, onChange }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomSelect
|
<div className="flex gap-2">
|
||||||
placeholder="+ Objekt hinzufügen"
|
{available.length > 0 && (
|
||||||
options={options}
|
<div className="flex-1">
|
||||||
onChange={add}
|
<CustomSelect
|
||||||
/>
|
placeholder="+ Objekte hinzufügen"
|
||||||
|
options={available.map((o) => ({ value: String(o.ID), label: o.Name }))}
|
||||||
|
onChange={add}
|
||||||
|
keepOpen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
+ Neu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showNewInput && (
|
{showNewInput && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -87,21 +92,22 @@ 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-3 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-base focus:border-blue-500 focus:outline-none"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addNew}
|
onClick={addNew}
|
||||||
className="px-4 py-3 bg-green-600 text-white text-base rounded-lg hover:bg-green-700"
|
className="px-4 py-2 bg-green-600 text-white text-base 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-3 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-base rounded-lg hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
Abbrechen
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
68
components/TimePicker5.tsx
Normal file
68
components/TimePicker5.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string; // "HH:MM"
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMinutes(time: string, delta: number): string {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
const total = ((h * 60 + m + delta) % (24 * 60) + 24 * 60) % (24 * 60);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimePicker5({ value, onChange, className = '' }: Props) {
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
function startRepeat(delta: number) {
|
||||||
|
onChange(addMinutes(valueRef.current, delta));
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
const hourDelta = delta > 0 ? 60 : -60;
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
onChange(addMinutes(valueRef.current, hourDelta));
|
||||||
|
}, 350);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRepeat() {
|
||||||
|
if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||||
|
if (intervalRef.current !== null) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonProps(delta: number) {
|
||||||
|
return {
|
||||||
|
type: 'button' as const,
|
||||||
|
tabIndex: -1,
|
||||||
|
onMouseDown: () => startRepeat(delta),
|
||||||
|
onMouseUp: stopRepeat,
|
||||||
|
onMouseLeave: stopRepeat,
|
||||||
|
onTouchStart: (e: React.TouchEvent) => { e.preventDefault(); startRepeat(delta); },
|
||||||
|
onTouchEnd: stopRepeat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); onChange(addMinutes(value, 5)); }
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); onChange(addMinutes(value, -5)); }
|
||||||
|
}}
|
||||||
|
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 text-gray-900">{value}</span>
|
||||||
|
<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-0.5 pb-1 hover:bg-gray-100 text-gray-500 text-xs leading-none">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ CREATE TABLE objekte (
|
|||||||
CREATE TABLE logbuch (
|
CREATE TABLE logbuch (
|
||||||
ID INT AUTO_INCREMENT PRIMARY KEY,
|
ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
|
Kuppel ENUM('West','Ost','Süd','Pluto') NOT NULL DEFAULT 'West',
|
||||||
ArtFuehrung ENUM('Reguläre Führung','Sonderführung','BEO-Sitzung','Sonnenführung','Technischer Dienst','Beobachtung','Tag der offenen Tür','Sonstiges') NOT NULL DEFAULT 'Reguläre Führung',
|
ArtFuehrung ENUM('RF','SF','PrF','BEOS','SonF','TD','Beob','ToT','Sonst') NOT NULL DEFAULT 'RF',
|
||||||
Beginn DATETIME NOT NULL,
|
Beginn DATETIME NOT NULL,
|
||||||
Ende DATETIME NOT NULL,
|
Ende DATETIME NOT NULL,
|
||||||
Besucher INT DEFAULT 0,
|
Besucher INT DEFAULT 0,
|
||||||
|
|||||||
48
docker-compose.prod.yml
Normal file
48
docker-compose.prod.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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_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:
|
||||||
28
dump_for_server.sh
Executable file
28
dump_for_server.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Erstellt einen SQL-Dump der 5 App-Tabellen aus dem lokalen logbuch_mysql-Container.
|
||||||
|
# Die Datei logbuch_dump.sql dann auf den Server kopieren und dort server_import.sh ausführen.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER="logbuch_mysql"
|
||||||
|
DB="sternwarte"
|
||||||
|
ROOT_PASS="SFluorit"
|
||||||
|
TABLES="beos objekte logbuch logbuch_beos logbuch_objekte"
|
||||||
|
OUTFILE="logbuch_dump.sql"
|
||||||
|
|
||||||
|
echo "Exportiere Tabellen aus '$CONTAINER'..."
|
||||||
|
docker exec "$CONTAINER" mysqldump \
|
||||||
|
-u root -p"$ROOT_PASS" \
|
||||||
|
--single-transaction \
|
||||||
|
--no-tablespaces \
|
||||||
|
"$DB" $TABLES > "$OUTFILE"
|
||||||
|
|
||||||
|
echo "Dump gespeichert: $OUTFILE ($(wc -c < "$OUTFILE" | tr -d ' ') Bytes)"
|
||||||
|
echo ""
|
||||||
|
echo "Nächste Schritte:"
|
||||||
|
echo " scp $OUTFILE user@server:/opt/logbuch/"
|
||||||
|
echo " scp docker-compose.prod.yml user@server:/opt/logbuch/"
|
||||||
|
echo " scp .env.prod.example user@server:/opt/logbuch/.env.prod"
|
||||||
|
echo " # Auf dem Server: .env.prod befüllen, dann:"
|
||||||
|
echo " # docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d"
|
||||||
|
echo " # ./server_import.sh"
|
||||||
@@ -3,6 +3,7 @@ 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',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'logbuch_session';
|
const SESSION_COOKIE_NAME = 'logbuch_session';
|
||||||
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;
|
const SESSION_DURATION = 60 * 60 * 1000;
|
||||||
|
|
||||||
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
|
const secretKey = process.env.AUTH_SECRET || 'logbuch-secret-change-in-production';
|
||||||
const key = new TextEncoder().encode(secretKey);
|
const key = new TextEncoder().encode(secretKey);
|
||||||
|
|||||||
191
logbuch_dump.sql
Normal file
191
logbuch_dump.sql
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
-- 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',
|
||||||
|
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),(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$9EqDIW7xyTGsNE0NJqt5Pef1p/9C8FMj1tRfO0JPYkFaKouPey95W',0),(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),(6,'Förnzler','Ulrich','Fö','Ober Str. 12','7ß190','Stuttgart','papa.foernzler@gmx.de',NULL,'07112865188','01778239801',NULL,NULL,NULL,'m',12,NULL,NULL,NULL,0),(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),(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',NULL,0),(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),(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),(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,NULL,0),(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),(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),(17,'Keller','Hans-Ulrich','HUK','Planetarium','','Stuttgart','hans-ulrich.keller@stuttgart.de','planetarium@stuttgart.de','07114403350',NULL,'07111629226','07112163912',NULL,'m',1,NULL,NULL,NULL,0),(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,NULL,0),(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),(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),(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,NULL,0),(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),(23,'Schuler','Bernd','SC','','','','bkschuler@gmail.com',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mo I,Sa C',NULL,NULL,0),(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),(25,'Nastos','Xeno','XN','','','','XNastos@t-online.de',NULL,NULL,NULL,NULL,NULL,NULL,'m',NULL,'Mi II,Sa B',NULL,NULL,0),(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,NULL,0);
|
||||||
|
/*!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=13 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-04-29 08:07:16','2026-04-27 15:44:00'),(2,'Jupiter','2026-04-29 08:07:16','2026-04-27 15:44:00'),(3,'M57','2026-04-28 11:39:17','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-04-28 11:39:17','2026-04-28 11:39:17'),(8,'M45','2026-04-29 08:07:16','2026-04-29 07:59:50'),(9,'Saturn','2026-04-29 08:07:16','2026-04-29 08:06:16'),(10,'M42','2026-04-29 08:07:16','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');
|
||||||
|
/*!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,
|
||||||
|
PRIMARY KEY (`ID`),
|
||||||
|
KEY `created_by` (`created_by`),
|
||||||
|
CONSTRAINT `logbuch_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `beos` (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=16 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 (1,'West','RF','2026-04-27 20:00:00','2026-04-27 22:00:00',22,'Test\n',15.7,68.6,1006.3,7,'2026-04-27 15:44:00'),(2,'West','RF','2026-04-27 17:44:00','2026-04-27 17:44:00',0,NULL,11.6,89.0,1016.6,7,'2026-04-27 15:45:19'),(3,'West','RF','2026-04-27 17:52:00','2026-04-27 17:52:00',22,NULL,22.8,55.1,993.8,7,'2026-04-27 15:56:24'),(4,'West','RF','2026-04-27 18:06:00','2026-04-27 18:06:00',6,NULL,11.0,67.2,1002.7,7,'2026-04-27 16:06:59'),(5,'West','RF','2026-04-27 20:16:00','2026-04-27 20:16:00',1,NULL,20.3,67.9,1014.8,2,'2026-04-27 18:17:16'),(6,'West','RF','2026-04-28 09:45:00','2026-04-28 09:00:00',0,NULL,12.2,83.1,1017.1,2,'2026-04-28 07:00:01'),(8,'West','RF','2026-04-28 09:15:00','2026-04-28 09:15:00',0,NULL,9.6,56.8,1000.3,2,'2026-04-28 07:02:13'),(9,'West','RF','2026-04-28 09:15:00','2026-04-28 09:15:00',0,NULL,9.6,56.8,1000.3,2,'2026-04-28 07:02:41'),(14,'Pluto','Beob','2024-01-10 18:30:00','2024-01-10 23:00:00',0,NULL,16.4,43.4,1015.3,15,'2026-04-29 07:59:50'),(15,'West','Sonst','2024-01-20 18:00:00','2024-01-20 19:45:00',3,'Private Führung',8.5,53.0,1001.2,2,'2026-04-29 08:06:16');
|
||||||
|
/*!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=41 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 (1,1,7),(2,1,2),(3,1,23),(4,2,7),(5,2,21),(6,3,7),(7,3,9),(8,4,7),(9,4,15),(11,5,2),(12,6,2),(14,8,2),(15,9,2),(38,14,15),(40,15,2);
|
||||||
|
/*!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=32 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 (1,1,1,'Mond'),(2,1,2,'Jupiter'),(3,1,3,'M57'),(4,2,2,'Jupiter'),(5,3,4,'Abend Stern'),(6,4,4,'Abend Stern'),(7,4,3,'M57'),(10,5,4,'Abend Stern'),(11,5,5,'eps Lyr'),(12,5,1,'Mond'),(13,5,6,'beta Cyg'),(19,14,8,'M45'),(26,15,2,'Jupiter'),(27,15,10,'M42'),(28,15,8,'M45'),(29,15,1,'Mond'),(30,15,9,'Saturn'),(31,15,12,'alpha Ori');
|
||||||
|
/*!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-04-30 5:31:48
|
||||||
16
migrate_art.sql
Normal file
16
migrate_art.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: ArtFuehrung von Langtext auf Kürzel umstellen
|
||||||
|
-- Schritt 1: ENUM zu VARCHAR öffnen (nimmt alle Werte an)
|
||||||
|
ALTER TABLE logbuch MODIFY ArtFuehrung VARCHAR(50) NOT NULL DEFAULT 'RF';
|
||||||
|
|
||||||
|
-- Schritt 2: Bestehende Daten umschreiben
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'RF' WHERE ArtFuehrung = 'Reguläre Führung';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'SF' WHERE ArtFuehrung = 'Sonderführung';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'BEOS' WHERE ArtFuehrung = 'BEO-Sitzung';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'SonF' WHERE ArtFuehrung = 'Sonnenführung';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'TD' WHERE ArtFuehrung = 'Technischer Dienst';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'Beob' WHERE ArtFuehrung = 'Beobachtung';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'ToT' WHERE ArtFuehrung = 'Tag der offenen Tür';
|
||||||
|
UPDATE logbuch SET ArtFuehrung = 'Sonst' WHERE ArtFuehrung = 'Sonstiges';
|
||||||
|
|
||||||
|
-- Schritt 3: Zurück zu ENUM mit neuen Kürzeln
|
||||||
|
ALTER TABLE logbuch MODIFY ArtFuehrung ENUM('RF','SF','BEOS','SonF','TD','Beob','ToT','Sonst') NOT NULL DEFAULT 'RF';
|
||||||
103
migrate_db.sh
Executable file
103
migrate_db.sh
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Erstellt einen neuen MySQL-Container "logbuch_mysql" und migriert
|
||||||
|
# die 5 App-relevanten Tabellen aus dem bestehenden "db"-Container.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Konfiguration ────────────────────────────────────────────────────────────
|
||||||
|
SRC_CONTAINER="db"
|
||||||
|
SRC_DB="sternwarte"
|
||||||
|
SRC_ROOT_PASS="SFluorit"
|
||||||
|
|
||||||
|
NEW_CONTAINER="logbuch_mysql"
|
||||||
|
NEW_DB="sternwarte"
|
||||||
|
NEW_ROOT_PASS="SFluorit" # ggf. hier ändern
|
||||||
|
NEW_PORT="3307" # Host-Port (3306 ist schon belegt)
|
||||||
|
MYSQL_IMAGE="arm64v8/mysql:lts"
|
||||||
|
NETWORK="sternwarte_default"
|
||||||
|
|
||||||
|
TABLES="beos objekte logbuch logbuch_beos logbuch_objekte"
|
||||||
|
|
||||||
|
# ── Hilfsfunktion ────────────────────────────────────────────────────────────
|
||||||
|
wait_for_mysql() {
|
||||||
|
local container="$1"
|
||||||
|
local pass="$2"
|
||||||
|
echo -n "Warte auf MySQL in '$container' "
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker exec "$container" mysqladmin ping -u root -p"$pass" --silent 2>/dev/null; then
|
||||||
|
echo " bereit."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "FEHLER: MySQL in '$container' nicht erreichbar nach 120s." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Prüfen ob Quell-Container läuft ─────────────────────────────────────────
|
||||||
|
if ! docker inspect "$SRC_CONTAINER" &>/dev/null; then
|
||||||
|
echo "FEHLER: Quell-Container '$SRC_CONTAINER' nicht gefunden." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Quell-Container '$SRC_CONTAINER' gefunden."
|
||||||
|
|
||||||
|
# ── Alten Ziel-Container entfernen falls vorhanden ──────────────────────────
|
||||||
|
if docker inspect "$NEW_CONTAINER" &>/dev/null; then
|
||||||
|
echo "Container '$NEW_CONTAINER' existiert bereits — wird gestoppt und entfernt..."
|
||||||
|
docker rm -f "$NEW_CONTAINER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Neuen Container starten ──────────────────────────────────────────────────
|
||||||
|
echo "Starte neuen Container '$NEW_CONTAINER'..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$NEW_CONTAINER" \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
-p "${NEW_PORT}:3306" \
|
||||||
|
-e MYSQL_ROOT_PASSWORD="$NEW_ROOT_PASS" \
|
||||||
|
-e MYSQL_DATABASE="$NEW_DB" \
|
||||||
|
"$MYSQL_IMAGE"
|
||||||
|
|
||||||
|
wait_for_mysql "$NEW_CONTAINER" "$NEW_ROOT_PASS"
|
||||||
|
|
||||||
|
# ── Daten migrieren ──────────────────────────────────────────────────────────
|
||||||
|
echo "Migriere Tabellen: $TABLES"
|
||||||
|
echo "(Dump von '$SRC_CONTAINER' → Import in '$NEW_CONTAINER')"
|
||||||
|
|
||||||
|
docker exec "$SRC_CONTAINER" mysqldump \
|
||||||
|
-u root -p"$SRC_ROOT_PASS" \
|
||||||
|
--single-transaction \
|
||||||
|
--no-tablespaces \
|
||||||
|
"$SRC_DB" $TABLES \
|
||||||
|
| docker exec -i "$NEW_CONTAINER" mysql \
|
||||||
|
-u root -p"$NEW_ROOT_PASS" \
|
||||||
|
"$NEW_DB"
|
||||||
|
|
||||||
|
echo "Migration abgeschlossen."
|
||||||
|
|
||||||
|
# ── Zeilenzähler zur Verifikation ────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "Zeilenzähler (Quelle → Ziel):"
|
||||||
|
for TABLE in $TABLES; do
|
||||||
|
SRC_COUNT=$(docker exec "$SRC_CONTAINER" mysql -u root -p"$SRC_ROOT_PASS" -sN \
|
||||||
|
-e "SELECT COUNT(*) FROM $TABLE;" "$SRC_DB" 2>/dev/null)
|
||||||
|
DST_COUNT=$(docker exec "$NEW_CONTAINER" mysql -u root -p"$NEW_ROOT_PASS" -sN \
|
||||||
|
-e "SELECT COUNT(*) FROM $TABLE;" "$NEW_DB" 2>/dev/null)
|
||||||
|
STATUS="✓"
|
||||||
|
[ "$SRC_COUNT" != "$DST_COUNT" ] && STATUS="✗ ABWEICHUNG"
|
||||||
|
printf " %-25s %5s → %5s %s\n" "$TABLE" "$SRC_COUNT" "$DST_COUNT" "$STATUS"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Abschluss ─────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════"
|
||||||
|
echo "Neuer Container: $NEW_CONTAINER"
|
||||||
|
echo " Netzwerk: $NETWORK"
|
||||||
|
echo " Host-Port: $NEW_PORT"
|
||||||
|
echo " Datenbank: $NEW_DB"
|
||||||
|
echo ""
|
||||||
|
echo "Nächster Schritt — .env anpassen:"
|
||||||
|
echo " DB_HOST=$NEW_CONTAINER"
|
||||||
|
echo " DB_PASS=$NEW_ROOT_PASS"
|
||||||
|
echo "══════════════════════════════════════════════════════"
|
||||||
33
server_import.sh
Executable file
33
server_import.sh
Executable 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."
|
||||||
@@ -1,25 +1,21 @@
|
|||||||
export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto';
|
export type Kuppel = 'West' | 'Ost' | 'Süd' | 'Pluto';
|
||||||
export type ArtFuehrung =
|
export type ArtFuehrung = 'RF' | 'SF' | 'PrF' | 'BEOS' | 'SonF' | 'TD' | 'Beob' | 'ToT' | 'Sonst';
|
||||||
| 'Reguläre Führung'
|
|
||||||
| 'Sonderführung'
|
|
||||||
| 'BEO-Sitzung'
|
|
||||||
| 'Sonnenführung'
|
|
||||||
| 'Technischer Dienst'
|
|
||||||
| 'Beobachtung'
|
|
||||||
| 'Tag der offenen Tür'
|
|
||||||
| 'Sonstiges';
|
|
||||||
|
|
||||||
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
|
export const KUPPELN: Kuppel[] = ['West', 'Ost', 'Süd', 'Pluto'];
|
||||||
export const ARTEN: ArtFuehrung[] = [
|
|
||||||
'Reguläre Führung',
|
export const ARTEN_MAP: Record<ArtFuehrung, string> = {
|
||||||
'Sonderführung',
|
RF: 'Reguläre Führung',
|
||||||
'BEO-Sitzung',
|
SF: 'Sonderführung',
|
||||||
'Sonnenführung',
|
PrF: 'Private Führung',
|
||||||
'Technischer Dienst',
|
BEOS: 'BEO-Sitzung',
|
||||||
'Beobachtung',
|
SonF: 'Sonnenführung',
|
||||||
'Tag der offenen Tür',
|
TD: 'Technischer Dienst',
|
||||||
'Sonstiges',
|
Beob: 'Beobachtung',
|
||||||
];
|
ToT: 'Tag der offenen Tür',
|
||||||
|
Sonst:'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARTEN = Object.keys(ARTEN_MAP) as ArtFuehrung[];
|
||||||
|
|
||||||
export interface BeoOption {
|
export interface BeoOption {
|
||||||
ID: number;
|
ID: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user