commit 01a7ac8e0b5b31278e49e58713325dfc55798020 Author: Reinhard X. Fürst Date: Mon Jun 22 09:53:44 2026 +0200 Initiale Arbeitszeiterfassung (Next.js 16 + SQLite) Web-App zur Erfassung der Arbeitszeit, Layout/Architektur an Projekt logbuch angelehnt, mit grüner Grundfarbe und SQLite3 statt MySQL. - Eingabemaske: Datum (mit Wochentag), Ort (Kunde FFM/Homeoffice/Andrena), Beginn/Ende, Reisezeit, Vertriebsunterstützung (default 2h), Kommentar (max 500) - Berechnung: Arbeitszeit = Ende-Beginn; Mehrarbeit = Arbeitszeit - 8h - Pause - Pause als globale Einstellung (Einstellungen-Tab) - Tabs Eingabe/Liste/Einstellungen, letzte 10 Einträge unter der Maske - Single-User-Login (Benutzer Birgit + Passwort fest in .env), JWT-Cookie via jose - better-sqlite3, Docker-Setup für docker.citysensor.de Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d5ede1f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +data +.env +.env.* +npm-debug.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3c24d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# SQLite-Datenbank (Laufzeitdaten) +/data/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..688604b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +Hinweise für Claude Code (claude.ai/code) zur Arbeit in diesem Repository. + +## Commands + +```bash +npm run dev # Entwicklungsserver +npm run build # Produktions-Build (nach jeder Änderung zur Prüfung ausführen) +npm run lint # ESLint +``` + +Kein Test-Setup. Deploy via `./deploy.sh [tag]` — baut ein Multiplatform-Docker-Image +(amd64 + arm64) und pusht es nach `docker.citysensor.de`. + +## Architektur + +Next.js 16 App-Router-Anwendung. Seiten sind Server-Komponenten; interaktive Teile sind +Client-Komponenten in `app/MainClient.tsx` und `components/`. Layout und Design sind dem +Schwesterprojekt `~/Projekte/logbuch` nachempfunden, jedoch mit **grüner** Grundfarbe. + +**Auth**: Single-User. Benutzername und Passwort sind fest in `.env` hinterlegt +(`APP_USERNAME` default `Birgit`, `APP_PASSWORD`). Login über `app/login/actions.ts` → +`lib/auth.ts` (Plaintext-Vergleich, kein bcrypt). Sessions sind JWT-Cookies via `jose` +(`lib/session.ts`, 8 h Gültigkeit, Cookie `arbeitszeit_session`). Middleware liegt in +`proxy.ts` (Next.js-16-Konvention) und exportiert `middleware`. + +**Datenbank**: SQLite3 via `better-sqlite3`. `lib/db.ts` ist ein Singleton, legt Schema und +die `settings`-Tabelle beim ersten Zugriff an. Datei unter `DB_PATH` +(default `./data/arbeitszeit.db`, in Produktion `/app/data/arbeitszeit.db` auf einem Volume). +`better-sqlite3` ist nativ → `serverExternalPackages` in `next.config.ts` und im Dockerfile +`python3 make g++` in der `deps`-Stage. + +**Datenzugriff**: `lib/repo.ts` (CRUD + Pause-Setting), `lib/calc.ts` (Zeit-/Wochentag-Helfer), +`lib/validate.ts` (Body-Validierung). API-Routen unter `app/api/` prüfen alle `getSession()` +und liefern 401 ohne Session. + +## Datenmodell & Berechnungen + +Tabelle `arbeitszeit`: `datum`, `ort` (Kunde FFM | Homeoffice | Andrena), `beginn`, `ende` +(je HH:MM), `reisezeit`, `vertriebsunterstuetzung` (default 2), `kommentar` (max. 500). + +- `Arbeitszeit = Ende − Beginn` (dezimale Stunden) +- `Mehrarbeit = Arbeitszeit − 8 − Pause` +- Die Spalte „Arbeitszeit Kunde" ist fix 8 h (`ARBEITSZEIT_KUNDE_FIX`). +- Die **Pause** ist eine globale Einstellung in der `settings`-Tabelle (Tab „Einstellungen"). + Mehrarbeit wird zur Laufzeit berechnet, nicht gespeichert. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..559ca1c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM node:22-alpine AS base + +FROM base AS deps +# Build-Tools für die native Kompilierung von better-sqlite3 (node-gyp) +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG BUILD_DATE +ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +# Verzeichnis für die SQLite-Datenbank (per Volume gemountet) +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + +USER nextjs + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV DB_PATH=/app/data/arbeitszeit.db + +CMD ["node", "server.js"] diff --git a/app/MainClient.tsx b/app/MainClient.tsx new file mode 100644 index 0000000..13e85b8 --- /dev/null +++ b/app/MainClient.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { ArbeitszeitEintrag } from '@/types/arbeitszeit'; +import ArbeitszeitForm from '@/components/ArbeitszeitForm'; +import ArbeitszeitList from '@/components/ArbeitszeitList'; +import Einstellungen from '@/components/Einstellungen'; +import packageJson from '@/package.json'; + +interface Props { + username: string; +} + +type Tab = 'eingabe' | 'liste' | 'einstellungen'; + +export default function MainClient({ username }: Props) { + const [activeTab, setActiveTab] = useState('eingabe'); + const [refreshKey, setRefreshKey] = useState(0); + const [editEntry, setEditEntry] = useState(null); + const [pause, setPause] = useState(0.5); + + const version = packageJson.version; + const buildDate = + process.env.NEXT_PUBLIC_BUILD_DATE || + new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + useEffect(() => { + fetch('/api/settings') + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data && typeof data.pause === 'number') setPause(data.pause); }) + .catch(() => {}); + }, []); + + function handleSaved() { + setRefreshKey((k) => k + 1); + setEditEntry(null); + } + + function handleEdit(entry: ArbeitszeitEintrag) { + setEditEntry(entry); + setActiveTab('eingabe'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + async function handleLogout() { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/login'; + } + + const tabs: { id: Tab; label: string }[] = [ + { id: 'eingabe', label: 'Eingabe' }, + { id: 'liste', label: 'Liste' }, + { id: 'einstellungen', label: 'Einstellungen' }, + ]; + + return ( +
+
+ + {/* Header */} +
+

+ Arbeitszeiterfassung +  — {username} +

+ +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Eingabe-Tab */} + {activeTab === 'eingabe' && ( + <> + {editEntry && ( +
+ Eintrag bearbeiten (ID {editEntry.id}) +
+ )} + setEditEntry(null)} + /> + + {/* Letzte 10 Einträge unter der Eingabemaske */} +
+
+
+

Letzte Einträge

+ +
+ +
+
+ + )} + + {/* Liste-Tab */} + {activeTab === 'liste' && ( +
+ +
+ )} + + {/* Einstellungen-Tab */} + {activeTab === 'einstellungen' && ( +
+

Einstellungen

+ +
+ )} + + +
+
+ ); +} diff --git a/app/api/arbeitszeit/[id]/route.ts b/app/api/arbeitszeit/[id]/route.ts new file mode 100644 index 0000000..366785a --- /dev/null +++ b/app/api/arbeitszeit/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { updateArbeitszeit, deleteArbeitszeit } from '@/lib/repo'; +import { parseArbeitszeitInput } from '@/lib/validate'; + +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const id = parseInt((await params).id, 10); + if (isNaN(id)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); + + try { + const input = parseArbeitszeitInput(await request.json()); + const entry = updateArbeitszeit(id, input); + if (!entry) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 }); + return NextResponse.json(entry); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Datenbankfehler'; + console.error('PUT /api/arbeitszeit/[id]:', error); + return NextResponse.json({ error: msg }, { status: 400 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const id = parseInt((await params).id, 10); + if (isNaN(id)) return NextResponse.json({ error: 'Ungültige ID' }, { status: 400 }); + + try { + const ok = deleteArbeitszeit(id); + if (!ok) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('DELETE /api/arbeitszeit/[id]:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/api/arbeitszeit/route.ts b/app/api/arbeitszeit/route.ts new file mode 100644 index 0000000..b75e1be --- /dev/null +++ b/app/api/arbeitszeit/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { listArbeitszeit, createArbeitszeit } from '@/lib/repo'; +import { parseArbeitszeitInput } from '@/lib/validate'; + +export async function GET(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const limit = Math.min(parseInt(searchParams.get('limit') || '15') || 15, 500); + const offset = Math.max(0, parseInt(searchParams.get('offset') || '0') || 0); + const month = searchParams.get('month') || undefined; + + try { + const result = listArbeitszeit({ limit, offset, month }); + return NextResponse.json(result); + } catch (error) { + console.error('GET /api/arbeitszeit:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + const input = parseArbeitszeitInput(await request.json()); + const entry = createArbeitszeit(input); + return NextResponse.json(entry, { status: 201 }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Datenbankfehler'; + console.error('POST /api/arbeitszeit:', error); + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..302e826 --- /dev/null +++ b/app/api/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; +import { deleteSession } from '@/lib/session'; + +export async function POST() { + await deleteSession(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..9ff2b3e --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { getPause, setPause } from '@/lib/repo'; +import { toNumber } from '@/lib/validate'; + +export async function GET() { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + return NextResponse.json({ pause: getPause() }); + } catch (error) { + console.error('GET /api/settings:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + const body = await request.json(); + const pause = toNumber(body?.pause, NaN); + if (isNaN(pause) || pause < 0 || pause > 24) { + return NextResponse.json({ error: 'Ungültiger Pausenwert.' }, { status: 400 }); + } + setPause(pause); + return NextResponse.json({ pause }); + } catch (error) { + console.error('PUT /api/settings:', error); + return NextResponse.json({ error: 'Datenbankfehler' }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..19c29aa --- /dev/null +++ b/app/globals.css @@ -0,0 +1,40 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} + +@media print { + @page { + size: A4 portrait; + margin: 1.5cm; + } + body { + background: white; + } + table { + font-size: 0.72rem !important; + width: 95% !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a583e51 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata, Viewport } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Arbeitszeiterfassung', + description: 'Erfassung der Arbeitszeit', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..37e1716 --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,42 @@ +'use server'; + +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { verifyCredentials, getConfiguredUsername } from '@/lib/auth'; +import { createSession } from '@/lib/session'; +import { checkRateLimit } from '@/lib/ratelimit'; + +export async function login( + _prevState: { error: string } | undefined, + formData: FormData +): Promise<{ error: string }> { + const headersList = await headers(); + const ip = + headersList.get('x-forwarded-for')?.split(',')[0].trim() ?? + headersList.get('x-real-ip') ?? + 'unknown'; + + const { allowed, remainingMs } = checkRateLimit(ip); + if (!allowed) { + const minutes = Math.ceil(remainingMs / 60000); + return { error: `Zu viele Anmeldeversuche. Bitte ${minutes} Minute${minutes !== 1 ? 'n' : ''} warten.` }; + } + + const username = (formData.get('username') as string)?.trim(); + const password = formData.get('password') as string; + + if (!username || !password) { + return { error: 'Bitte Benutzer und Passwort eingeben.' }; + } + + if (!verifyCredentials(username, password)) { + return { error: 'Ungültiger Benutzer oder Passwort.' }; + } + + await createSession({ + username: getConfiguredUsername(), + isAuthenticated: true, + }); + + redirect('/'); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..a705c41 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useActionState, useState } from 'react'; +import { login } from './actions'; +import packageJson from '@/package.json'; + +export default function LoginPage() { + const [state, loginAction, isPending] = useActionState(login, undefined); + const [showPassword, setShowPassword] = useState(false); + + const version = packageJson.version; + const buildDate = + process.env.NEXT_PUBLIC_BUILD_DATE || + new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + + return ( +
+
+
+

Arbeitszeiterfassung

+
+ +
+
+

Anmeldung

+ +
+
+ + +
+ +
+ +
+ + +
+
+ + {state?.error && ( +
+ {state.error} +
+ )} + + +
+
+
+ + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..60b3798 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'next/navigation'; +import { getSession } from '@/lib/session'; +import MainClient from './MainClient'; + +export default async function HomePage() { + const session = await getSession(); + if (!session) redirect('/login'); + + return ; +} diff --git a/components/ArbeitszeitForm.tsx b/components/ArbeitszeitForm.tsx new file mode 100644 index 0000000..27cd45f --- /dev/null +++ b/components/ArbeitszeitForm.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useState } from 'react'; +import { ORTE, ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit'; +import { toDecimalHours, mehrarbeit, weekdayName, formatHours } from '@/lib/calc'; +import CustomSelect from './CustomSelect'; +import TimeInput from './TimeInput'; + +interface Props { + editEntry?: ArbeitszeitEintrag | null; + pause: number; + onSaved: () => void; + onCancel: () => void; +} + +function todayDate(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +// Komma als Dezimaltrenner erlauben +function toNum(raw: string): number { + const n = parseFloat(raw.replace(',', '.')); + return isNaN(n) ? 0 : n; +} + +export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }: Props) { + // Diese Komponente wird beim Wechsel von editEntry über `key` neu gemountet, + // daher genügt eine einmalige Initialisierung aus editEntry (kein Sync-Effekt nötig). + const [datum, setDatum] = useState(editEntry?.datum ?? todayDate()); + const [ort, setOrt] = useState(editEntry?.ort ?? ORTE[0]); + const [beginn, setBeginn] = useState(editEntry?.beginn ?? '09:00'); + const [ende, setEnde] = useState(editEntry?.ende ?? '17:00'); + const [reisezeit, setReisezeit] = useState(editEntry ? String(editEntry.reisezeit) : '0'); + const [vertrieb, setVertrieb] = useState(editEntry ? String(editEntry.vertriebsunterstuetzung) : '2'); + const [kommentar, setKommentar] = useState(editEntry?.kommentar ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const arbeitszeit = toDecimalHours(beginn, ende); + const mehr = mehrarbeit(arbeitszeit, pause); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSuccess(false); + + if (arbeitszeit <= 0) { + setError('Endzeit muss nach der Startzeit liegen.'); + return; + } + + setSaving(true); + + const body = { + datum, + ort, + beginn, + ende, + reisezeit: toNum(reisezeit), + vertriebsunterstuetzung: toNum(vertrieb), + kommentar, + }; + + const url = editEntry ? `/api/arbeitszeit/${editEntry.id}` : '/api/arbeitszeit'; + const method = editEntry ? 'PUT' : 'POST'; + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Fehler beim Speichern.'); + } + setSuccess(true); + setTimeout(() => setSuccess(false), 4000); + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Speichern.'); + } finally { + setSaving(false); + } + } + + const labelCls = 'block text-xs font-medium text-gray-700 mb-0.5'; + const numCls = + 'w-24 px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none'; + const wt = weekdayName(datum); + + return ( +
+ {/* Datum + Wochentag / Ort */} +
+
+ +
+ e.target.value && setDatum(e.target.value)} + required + className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-green-600 focus:outline-none" + /> + {wt} +
+
+
+ + ({ value: o, label: o }))} + onChange={setOrt} + /> +
+
+ + {/* Beginn / Ende / Reisezeit / Vertrieb */} +
+
+ + +
+
+ + +
+
+ + setReisezeit(e.target.value)} + className={numCls} + /> +
+
+ + setVertrieb(e.target.value)} + className={numCls} + /> +
+
+ + {/* Kommentar */} +
+ +