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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:53:44 +02:00
commit 01a7ac8e0b
38 changed files with 9062 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
data
.env
.env.*
npm-debug.log
+44
View File
@@ -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/
+47
View File
@@ -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.
+44
View File
@@ -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"]
+156
View File
@@ -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<Tab>('eingabe');
const [refreshKey, setRefreshKey] = useState(0);
const [editEntry, setEditEntry] = useState<ArbeitszeitEintrag | null>(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 (
<div className="min-h-screen bg-white py-1 px-2 sm:py-2 sm:px-4 print:p-0">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-3 sm:p-4 bg-[#EEF7EE] print:max-w-none print:border-0 print:p-0 print:bg-white">
{/* Header */}
<div className="flex justify-between items-start sm:items-center mb-3 gap-2 print:hidden">
<h1 className="text-xl sm:text-2xl font-bold leading-tight text-gray-900">
Arbeitszeiterfassung
<span className="hidden sm:inline text-base font-normal text-gray-600"> &nbsp; {username}</span>
</h1>
<button
onClick={handleLogout}
className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded-lg text-white shrink-0"
>
Abmelden
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-3 border-b border-gray-200 print:hidden">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'eingabe') setEditEntry(null);
}}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-[#86C28B] text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Eingabe-Tab */}
{activeTab === 'eingabe' && (
<>
{editEntry && (
<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})
</div>
)}
<ArbeitszeitForm
key={editEntry?.id ?? 'new'}
editEntry={editEntry}
pause={pause}
onSaved={handleSaved}
onCancel={() => setEditEntry(null)}
/>
{/* Letzte 10 Einträge unter der Eingabemaske */}
<div className="mt-5 border-t-2 border-gray-300 pt-4">
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
<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-green-700 hover:underline"
>
Alle anzeigen
</button>
</div>
<ArbeitszeitList
pause={pause}
refreshKey={refreshKey}
onEdit={handleEdit}
limit={10}
compact
/>
</div>
</div>
</>
)}
{/* Liste-Tab */}
{activeTab === 'liste' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3 print:border-0 print:rounded-none print:p-0">
<ArbeitszeitList pause={pause} refreshKey={refreshKey} onEdit={handleEdit} limit={100} />
</div>
)}
{/* Einstellungen-Tab */}
{activeTab === 'einstellungen' && (
<div className="border-2 border-gray-400 rounded-xl bg-white p-3">
<h2 className="text-sm font-semibold text-gray-900 mb-3">Einstellungen</h2>
<Einstellungen pause={pause} onPauseChange={setPause} />
</div>
)}
<footer className="mt-6 flex justify-between items-center text-xs sm:text-sm text-gray-600 px-1 sm:px-4 print:hidden">
<a href="mailto:rxf@gmx.de" className="text-green-700 hover:underline">rxf@gmx.de</a>
<div className="text-right">v{version} {buildDate}</div>
</footer>
</main>
</div>
);
}
+40
View File
@@ -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 });
}
}
+37
View File
@@ -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 });
}
}
+7
View File
@@ -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 });
}
+34
View File
@@ -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 });
}
}
+40
View File
@@ -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;
}
}
+20
View File
@@ -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 (
<html lang="de">
<body className="antialiased">{children}</body>
</html>
);
}
+42
View File
@@ -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('/');
}
+111
View File
@@ -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 (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#EEF7EE]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Arbeitszeiterfassung</h1>
</div>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
<form action={loginAction} className="space-y-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Benutzer
</label>
<input
id="username"
name="username"
type="text"
required
defaultValue="Birgit"
autoComplete="username"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-green-600 focus:outline-none text-sm"
placeholder="Benutzer"
disabled={isPending}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Passwort
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
autoComplete="current-password"
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-green-600 focus:outline-none text-sm"
placeholder="Passwort"
disabled={isPending}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<div>
<a href="mailto:rxf@gmx.de" className="text-green-700 hover:underline">
rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version} {buildDate}
</div>
</footer>
</main>
</div>
);
}
+10
View File
@@ -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 <MainClient username={session.username} />;
}
+219
View File
@@ -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<string>(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 (
<form
onSubmit={handleSubmit}
className="space-y-3 max-w-2xl mx-auto border-2 border-gray-400 rounded-xl p-3 bg-white"
>
{/* Datum + Wochentag / Ort */}
<div className="flex flex-wrap gap-3 items-end">
<div className="shrink-0">
<label className={labelCls}>Datum</label>
<div className="flex items-center gap-2">
<input
type="date"
value={datum}
onChange={(e) => 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"
/>
<span className="text-sm font-medium text-green-800 min-w-[5.5rem]">{wt}</span>
</div>
</div>
<div className="flex-1 min-w-[160px]">
<label className={labelCls}>Ort</label>
<CustomSelect
placeholder={ort}
options={ORTE.map((o) => ({ value: o, label: o }))}
onChange={setOrt}
/>
</div>
</div>
{/* Beginn / Ende / Reisezeit / Vertrieb */}
<div className="flex flex-wrap gap-3 items-end">
<div className="shrink-0">
<label className={labelCls}>Beginn</label>
<TimeInput value={beginn} onChange={setBeginn} className="w-24" />
</div>
<div className="shrink-0">
<label className={labelCls}>Ende</label>
<TimeInput value={ende} onChange={setEnde} className="w-24" />
</div>
<div className="shrink-0">
<label className={labelCls}>Reisezeit (h)</label>
<input
type="text"
inputMode="decimal"
value={reisezeit}
onChange={(e) => setReisezeit(e.target.value)}
className={numCls}
/>
</div>
<div className="shrink-0">
<label className={labelCls}>Vertriebsunterst. (h)</label>
<input
type="text"
inputMode="decimal"
value={vertrieb}
onChange={(e) => setVertrieb(e.target.value)}
className={numCls}
/>
</div>
</div>
{/* Kommentar */}
<div>
<label className={labelCls}>
Kommentar
<span className="ml-2 text-gray-400 font-normal text-xs">({kommentar.length}/500)</span>
</label>
<textarea
value={kommentar}
onChange={(e) => setKommentar(e.target.value.slice(0, 500))}
rows={2}
className="w-full 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 resize-y"
placeholder="Freier Text (max. 500 Zeichen)"
/>
</div>
{/* Berechnungs-Vorschau */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm bg-[#EEF7EE] border border-green-200 rounded-lg px-3 py-2">
<span>
Arbeitszeit: <strong>{formatHours(arbeitszeit)} h</strong>
</span>
<span>
Arbeitszeit Kunde: <strong>{formatHours(ARBEITSZEIT_KUNDE_FIX)} h</strong>
</span>
<span>
Pause: <strong>{formatHours(pause)} h</strong>
</span>
<span>
Mehrarbeit: <strong className={mehr < 0 ? 'text-red-600' : 'text-green-800'}>{formatHours(mehr)} h</strong>
</span>
</div>
{error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
{success && !editEntry && (
<div className="bg-green-50 border border-green-300 text-green-700 px-3 py-2 rounded-lg text-sm">
Eintrag gespeichert.
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
type="submit"
disabled={saving}
className="w-full sm:w-auto px-6 py-1.5 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
>
{saving ? 'Speichern...' : editEntry ? 'Änderungen speichern' : 'Eintrag speichern'}
</button>
{editEntry && (
<button
type="button"
onClick={onCancel}
className="w-full sm:w-auto px-6 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-lg transition-colors text-sm"
>
Abbrechen
</button>
)}
</div>
</form>
);
}
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { useEffect, useState } from 'react';
import { ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit';
import { toDecimalHours, mehrarbeit, weekdayName, formatHours } from '@/lib/calc';
interface Props {
pause: number;
refreshKey: number;
onEdit: (entry: ArbeitszeitEintrag) => void;
limit?: number;
compact?: boolean;
}
const pad = (n: number) => String(n).padStart(2, '0');
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
return `${MONATE[m - 1]} ${y}`;
}
function prevMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 1 ? `${y - 1}-12` : `${y}-${pad(m - 1)}`;
}
function nextMonth(ym: string) {
const [y, m] = ym.split('-').map(Number);
return m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
}
function formatDate(d: string) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d);
return m ? `${m[3]}.${m[2]}.${m[1]}` : d;
}
export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15, compact = false }: Props) {
const [entries, setEntries] = useState<ArbeitszeitEintrag[]>([]);
const [month, setMonth] = useState(compact ? '' : currentMonth());
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleteErr, setDeleteErr] = useState('');
// Lade-/Fehlerzustand wird aus dem Ergebnis abgeleitet, nicht synchron im Effekt gesetzt.
const paramsKey = `${refreshKey}|${limit}|${month}`;
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forKey: string } | null>(null);
const loading = fetchResult?.forKey !== paramsKey;
const fetchError = fetchResult?.forKey === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
const error = fetchError || deleteErr;
useEffect(() => {
let cancelled = false;
const url =
`/api/arbeitszeit?limit=${limit}&offset=0` + (month ? `&month=${encodeURIComponent(month)}` : '');
fetch(url)
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => {
if (!cancelled) {
setEntries(data.entries);
setDeleteErr('');
setFetchResult({ ok: true, forKey: paramsKey });
}
})
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forKey: paramsKey }); });
return () => { cancelled = true; };
}, [refreshKey, limit, month, paramsKey]);
async function confirmDelete(id: number) {
try {
const res = await fetch(`/api/arbeitszeit/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error();
setEntries((prev) => prev.filter((e) => e.id !== id));
} catch {
setDeleteErr('Fehler beim Löschen.');
} finally {
setDeleteId(null);
}
}
const cell = compact
? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
: 'px-3 py-2 border border-gray-200 text-gray-900';
const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
: 'px-3 py-2 border border-gray-300 text-gray-900';
return (
<div>
{!compact && (
<div className="flex items-center gap-1 mb-3 print:hidden">
<button onClick={() => setMonth((m) => prevMonth(m))} className="px-2 py-1 text-sm rounded-lg bg-[#86C28B] hover:bg-[#6FB075]"></button>
<input
type="month"
value={month}
max={currentMonth()}
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
className="border border-green-600 rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-green-700"
/>
<button onClick={() => setMonth((m) => nextMonth(m))} disabled={month >= currentMonth()} className="px-2 py-1 text-sm rounded-lg bg-[#86C28B] hover:bg-[#6FB075] disabled:opacity-40 disabled:cursor-not-allowed"></button>
{month !== currentMonth() && (
<button onClick={() => setMonth(currentMonth())} className="text-sm text-green-700 hover:underline ml-1">Aktueller Monat</button>
)}
</div>
)}
{loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
{!loading && !error && (
<div className="overflow-x-auto">
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
<thead>
<tr className="bg-gray-100 text-left">
<th className={`${head} whitespace-nowrap`}>Datum</th>
<th className={head}>Wochentag</th>
<th className={head}>Ort</th>
<th className={`${head} text-right whitespace-nowrap`}>Arbeitszeit Kunde</th>
<th className={`${head} text-right`}>Mehrarbeit</th>
<th className={`${head} text-right`}>Reisezeit</th>
<th className={`${head} text-right whitespace-nowrap`}>Vertriebsunt.</th>
<th className={head}>Kommentar</th>
<th className={`${head} text-center print:hidden`}>Aktionen</th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr>
<td colSpan={9} className="px-3 py-4 text-gray-500 text-sm text-center">
{month ? `Keine Einträge für ${monthLabel(month)}.` : 'Keine Einträge vorhanden.'}
</td>
</tr>
) : entries.map((e) => {
const mehr = mehrarbeit(toDecimalHours(e.beginn, e.ende), pause);
return (
<tr key={e.id} className="hover:bg-gray-50">
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.datum)}</td>
<td className={cell}>{weekdayName(e.datum)}</td>
<td className={cell}>{e.ort}</td>
<td className={`${cell} text-right`}>{formatHours(ARBEITSZEIT_KUNDE_FIX)}</td>
<td className={`${cell} text-right ${mehr < 0 ? 'text-red-600' : ''}`}>{formatHours(mehr)}</td>
<td className={`${cell} text-right`}>{formatHours(e.reisezeit)}</td>
<td className={`${cell} text-right`}>{formatHours(e.vertriebsunterstuetzung)}</td>
<td className={cell}>{e.kommentar}</td>
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
<button onClick={() => onEdit(e)} className="text-green-700 hover:text-green-900 mr-2 font-medium"></button>
<button onClick={() => setDeleteId(e.id)} className="text-red-600 hover:text-red-800 font-medium"></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{deleteId !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
<div className="flex gap-3 justify-end">
<button onClick={() => setDeleteId(null)} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm">Abbrechen</button>
<button onClick={() => confirmDelete(deleteId)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">Löschen</button>
</div>
</div>
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
options: SelectOption[];
placeholder: string;
onChange: (value: string) => void;
keepOpen?: boolean;
id?: string;
}
export default function CustomSelect({ options, placeholder, onChange, keepOpen = false, id }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [open]);
function select(value: string) {
if (!keepOpen) setOpen(false);
onChange(value);
}
return (
<div ref={ref} className="relative">
<button
id={id}
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-green-600 focus:outline-none"
>
<span>{placeholder}</span>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-72 overflow-y-auto">
{options.map((opt) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
onClick={() => select(opt.value)}
className="w-full text-left px-4 py-2 text-base text-gray-900 hover:bg-green-50 active:bg-green-100 border-b border-gray-100 last:border-0 disabled:text-gray-400 disabled:bg-gray-50"
>
{opt.label}
</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>
);
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
interface Props {
pause: number;
onPauseChange: (pause: number) => void;
}
export default function Einstellungen({ pause, onPauseChange }: Props) {
const [raw, setRaw] = useState(String(pause).replace('.', ','));
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<'idle' | 'ok' | 'error'>('idle');
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setStatus('idle');
const value = parseFloat(raw.replace(',', '.'));
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pause: value }),
});
if (!res.ok) throw new Error();
const data = await res.json();
onPauseChange(data.pause);
setRaw(String(data.pause).replace('.', ','));
setStatus('ok');
setTimeout(() => setStatus('idle'), 3000);
} catch {
setStatus('error');
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSave} className="max-w-md space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pause (dezimale Stunden)
</label>
<input
type="text"
inputMode="decimal"
value={raw}
onChange={(e) => setRaw(e.target.value)}
className="w-32 px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-green-600 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
Wird in der Mehrarbeit-Berechnung abgezogen: Mehrarbeit = Arbeitszeit 8 h Pause.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-1.5 bg-[#86C28B] hover:bg-[#6FB075] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
{status === 'ok' && <span className="text-green-700 text-sm">Gespeichert.</span>}
{status === 'error' && <span className="text-red-600 text-sm">Fehler beim Speichern.</span>}
</div>
</form>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
interface Props {
value: string; // "HH:MM"
onChange: (value: string) => void;
className?: string;
clearOnFocus?: boolean;
autoFocus?: boolean;
}
function isValid(t: string): boolean {
if (!/^\d{1,2}:\d{2}$/.test(t)) return false;
const [h, m] = t.split(':').map(Number);
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
}
function normalize(t: string): string {
const [h, m] = t.split(':').map(Number);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export default function TimeInput({ value, onChange, className = '', clearOnFocus = false, autoFocus = false }: Props) {
const [local, setLocal] = useState(value);
const [error, setError] = useState(false);
// Lokalen Bearbeitungstext zurücksetzen, wenn sich der Wert von außen ändert.
// Bewusster Prop→State-Sync (Hybrid aus controlled/uncontrolled Eingabe).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLocal(value);
setError(false);
}, [value]);
function handleChange(raw: string) {
setError(false);
// Auto-insert colon after the 2nd digit (only when adding, not deleting)
if (/^\d{2}$/.test(raw) && /^\d$/.test(local)) {
setLocal(raw + ':');
return;
}
setLocal(raw);
}
function handleFocus() {
if (clearOnFocus) {
setLocal('');
setError(false);
}
}
function handleBlur() {
if (local === '') {
setLocal(value);
setError(false);
return;
}
const expanded = /^\d{1,2}:?$/.test(local) ? local.replace(/:$/, '') + ':00' : local;
if (isValid(expanded)) {
const norm = normalize(expanded);
setLocal(norm);
setError(false);
onChange(norm);
} else {
setError(true);
}
}
return (
<div className={`relative ${className}`}>
<input
type="text"
inputMode="numeric"
value={local}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autoFocus}
placeholder="HH:MM"
maxLength={5}
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-green-600'
}`}
/>
{error && (
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
Ungültig (00:00 23:59)
</p>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
services:
arbeitszeit_app:
image: docker.citysensor.de/arbeitszeit:latest
container_name: arbeitszeit_app
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
DB_PATH: /app/data/arbeitszeit.db
volumes:
- arbeitszeit_data:/app/data
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
networks:
- arbeitszeit_net
volumes:
arbeitszeit_data:
networks:
arbeitszeit_net:
driver: bridge
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
set -e
REGISTRY="docker.citysensor.de"
IMAGE_NAME="arbeitszeit"
TAG="${1:-latest}"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "Arbeitszeit Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
echo ">>> Richte Multiplatform Builder ein..."
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
echo ">>> Baue Multiplatform Docker Image und pushe..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
--push \
.
if [ "${TAG}" != "latest" ]; then
echo ">>> Tagge ${IMAGE_NAME} als :latest..."
docker buildx imagetools create \
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
"${FULL_IMAGE}"
fi
echo ""
echo "=========================================="
echo "Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo ""
echo "Auf dem Server ausführen:"
echo " docker pull ${FULL_IMAGE}"
echo " docker compose -f docker-compose.prod.yml up -d"
echo ""
+22
View File
@@ -0,0 +1,22 @@
services:
arbeitszeit_app:
image: docker.citysensor.de/arbeitszeit:latest
container_name: arbeitszeit_app
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
DB_PATH: /app/data/arbeitszeit.db
volumes:
- arbeitszeit_data:/app/data
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
networks:
- arbeitszeit_net
volumes:
arbeitszeit_data:
networks:
arbeitszeit_net:
driver: bridge
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+15
View File
@@ -0,0 +1,15 @@
// Single-User-Authentifizierung: Benutzername und Passwort fest in .env hinterlegt.
const DEFAULT_USERNAME = 'Birgit';
export function getConfiguredUsername(): string {
return process.env.APP_USERNAME || DEFAULT_USERNAME;
}
export function verifyCredentials(username: string, password: string): boolean {
const expectedUser = getConfiguredUsername();
const expectedPw = process.env.APP_PASSWORD;
if (!expectedPw) throw new Error('APP_PASSWORD Umgebungsvariable ist nicht gesetzt!');
// Benutzername case-insensitiv, Passwort exakt.
return username.trim().toLowerCase() === expectedUser.toLowerCase() && password === expectedPw;
}
+56
View File
@@ -0,0 +1,56 @@
// Reine Hilfsfunktionen für Datum-/Zeit-Berechnungen (ohne DB-Zugriff).
const WOCHENTAGE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
];
/** "HH:MM" → Minuten seit Mitternacht, oder null bei ungültiger Eingabe. */
export function parseHM(hm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hm ?? '');
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return h * 60 + min;
}
/**
* Differenz zwischen Beginn und Ende in dezimalen Stunden.
* Gibt 0 zurück, wenn eine Zeit ungültig ist. Ende vor Beginn ⇒ 0.
*/
export function toDecimalHours(beginn: string, ende: string): number {
const b = parseHM(beginn);
const e = parseHM(ende);
if (b === null || e === null) return 0;
const diff = e - b;
if (diff <= 0) return 0;
return diff / 60;
}
/** Mehrarbeit = Arbeitszeit 8 Stunden Pause (kann negativ sein). */
export function mehrarbeit(arbeitszeitStd: number, pauseStd: number): number {
return arbeitszeitStd - 8 - pauseStd;
}
/** Deutscher Wochentagsname für ein "YYYY-MM-DD"-Datum, oder "" bei ungültig. */
export function weekdayName(datum: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datum ?? '');
if (!m) return '';
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (isNaN(d.getTime())) return '';
return WOCHENTAGE[d.getDay()];
}
/** Dezimalstunden deutsch formatieren, z.B. 0.5 → "0,5". */
export function formatHours(value: number): string {
return value.toLocaleString('de-DE', {
minimumFractionDigits: 1,
maximumFractionDigits: 2,
});
}
+49
View File
@@ -0,0 +1,49 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
let db: Database.Database | null = null;
function dbPath(): string {
return process.env.DB_PATH || './data/arbeitszeit.db';
}
/** Singleton-Zugriff auf die SQLite-Datenbank. Legt Datei + Schema beim ersten Aufruf an. */
export function getDb(): Database.Database {
if (db) return db;
const path = dbPath();
mkdirSync(dirname(path), { recursive: true });
db = new Database(path);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS arbeitszeit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum TEXT NOT NULL,
ort TEXT NOT NULL,
beginn TEXT NOT NULL,
ende TEXT NOT NULL,
reisezeit REAL NOT NULL DEFAULT 0,
vertriebsunterstuetzung REAL NOT NULL DEFAULT 2,
kommentar TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_arbeitszeit_datum ON arbeitszeit (datum);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Standard-Pause beim ersten Start setzen (0,5 h)
db.prepare(
`INSERT INTO settings (key, value) VALUES ('pause', '0.5')
ON CONFLICT(key) DO NOTHING`
).run();
return db;
}
+38
View File
@@ -0,0 +1,38 @@
// In-memory rate limiter funktioniert pro Prozess (single Docker container).
// Erlaubt MAX_ATTEMPTS Versuche pro IP innerhalb WINDOW_MS Millisekunden.
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000; // 15 Minuten
interface Entry {
count: number;
resetAt: number;
}
const store = new Map<string, Entry>();
// Aufräumen abgelaufener Einträge alle 5 Minuten
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt < now) store.delete(key);
}
}, 5 * 60 * 1000);
export function checkRateLimit(ip: string): { allowed: boolean; remainingMs: number } {
const now = Date.now();
const entry = store.get(ip);
if (!entry || entry.resetAt < now) {
store.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remainingMs: 0 };
}
entry.count += 1;
if (entry.count > MAX_ATTEMPTS) {
return { allowed: false, remainingMs: entry.resetAt - now };
}
return { allowed: true, remainingMs: 0 };
}
+89
View File
@@ -0,0 +1,89 @@
import { getDb } from './db';
import type { ArbeitszeitEintrag, ArbeitszeitInput } from '@/types/arbeitszeit';
interface ListParams {
limit: number;
offset: number;
month?: string; // "YYYY-MM"
}
interface ListResult {
entries: ArbeitszeitEintrag[];
total: number;
}
/** Einträge absteigend nach Datum (neueste zuerst), optional nach Monat gefiltert. */
export function listArbeitszeit({ limit, offset, month }: ListParams): ListResult {
const db = getDb();
const where = month ? `WHERE substr(datum, 1, 7) = ?` : '';
const args = month ? [month] : [];
const total = (
db.prepare(`SELECT COUNT(*) AS c FROM arbeitszeit ${where}`).get(...args) as { c: number }
).c;
const entries = db
.prepare(
`SELECT * FROM arbeitszeit ${where}
ORDER BY datum DESC, beginn DESC, id DESC
LIMIT ? OFFSET ?`
)
.all(...args, limit, offset) as ArbeitszeitEintrag[];
return { entries, total };
}
export function getArbeitszeit(id: number): ArbeitszeitEintrag | undefined {
return getDb().prepare('SELECT * FROM arbeitszeit WHERE id = ?').get(id) as
| ArbeitszeitEintrag
| undefined;
}
export function createArbeitszeit(input: ArbeitszeitInput): ArbeitszeitEintrag {
const db = getDb();
const info = db
.prepare(
`INSERT INTO arbeitszeit (datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar)
VALUES (@datum, @ort, @beginn, @ende, @reisezeit, @vertriebsunterstuetzung, @kommentar)`
)
.run(input);
return getArbeitszeit(Number(info.lastInsertRowid))!;
}
export function updateArbeitszeit(
id: number,
input: ArbeitszeitInput
): ArbeitszeitEintrag | undefined {
const db = getDb();
db.prepare(
`UPDATE arbeitszeit SET
datum = @datum, ort = @ort, beginn = @beginn, ende = @ende,
reisezeit = @reisezeit, vertriebsunterstuetzung = @vertriebsunterstuetzung,
kommentar = @kommentar
WHERE id = @id`
).run({ ...input, id });
return getArbeitszeit(id);
}
export function deleteArbeitszeit(id: number): boolean {
return getDb().prepare('DELETE FROM arbeitszeit WHERE id = ?').run(id).changes > 0;
}
// --- Einstellungen -------------------------------------------------------
export function getPause(): number {
const row = getDb().prepare(`SELECT value FROM settings WHERE key = 'pause'`).get() as
| { value: string }
| undefined;
const v = row ? parseFloat(row.value) : 0.5;
return isNaN(v) ? 0.5 : v;
}
export function setPause(value: number): void {
getDb()
.prepare(
`INSERT INTO settings (key, value) VALUES ('pause', @value)
ON CONFLICT(key) DO UPDATE SET value = @value`
)
.run({ value: String(value) });
}
+62
View File
@@ -0,0 +1,62 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
const SESSION_DURATION = 8 * 60 * 60 * 1000; // 8 Stunden
function getKey(): Uint8Array {
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
return new TextEncoder().encode(secretKey);
}
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
async function encrypt(payload: SessionData): Promise<string> {
return new SignJWT(payload as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(getKey());
}
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, getKey(), { algorithms: ['HS256'] });
return payload as unknown as SessionData;
} catch {
return null;
}
}
export async function createSession(data: Omit<SessionData, 'expiresAt'>): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = { ...data, expiresAt };
const encrypted = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encrypted, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) return null;
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) return null;
return session;
}
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
+39
View File
@@ -0,0 +1,39 @@
import { ORTE, type ArbeitszeitInput, type Ort } from '@/types/arbeitszeit';
import { parseHM } from './calc';
/**
* Validiert und normalisiert einen JSON-Body zu einem ArbeitszeitInput.
* Wirft Error mit deutscher Meldung bei ungültigen Daten.
*/
export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
const b = (body ?? {}) as Record<string, unknown>;
const datum = String(b.datum ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(datum)) throw new Error('Ungültiges Datum.');
const ort = String(b.ort ?? '') as Ort;
if (!ORTE.includes(ort)) throw new Error('Ungültiger Ort.');
const beginn = String(b.beginn ?? '');
const ende = String(b.ende ?? '');
if (parseHM(beginn) === null) throw new Error('Ungültige Startzeit.');
if (parseHM(ende) === null) throw new Error('Ungültige Endzeit.');
const reisezeit = toNumber(b.reisezeit, 0);
const vertriebsunterstuetzung = toNumber(b.vertriebsunterstuetzung, 2);
if (reisezeit < 0 || vertriebsunterstuetzung < 0) {
throw new Error('Zeitwerte dürfen nicht negativ sein.');
}
const kommentar = String(b.kommentar ?? '').slice(0, 500);
return { datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar };
}
function toNumber(value: unknown, fallback: number): number {
if (value === '' || value === null || value === undefined) return fallback;
const n = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.'));
return isNaN(n) ? fallback : n;
}
export { toNumber };
+30
View File
@@ -0,0 +1,30 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
// Projektverzeichnis als Workspace-Root fixieren (mehrere Lockfiles im Baum)
turbopack: {
root: import.meta.dirname,
},
outputFileTracingRoot: import.meta.dirname,
// better-sqlite3 ist ein natives Modul und darf nicht gebundlet werden
serverExternalPackages: ['better-sqlite3'],
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
];
},
};
export default nextConfig;
+7167
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "arbeitszeit",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"jose": "^6.2.2",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'arbeitszeit_session';
const secretKey = process.env.AUTH_SECRET;
if (!secretKey) throw new Error('AUTH_SECRET Umgebungsvariable ist nicht gesetzt!');
const key = new TextEncoder().encode(secretKey);
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/login') || pathname.startsWith('/_next') || pathname.startsWith('/favicon')) {
return NextResponse.next();
}
const cookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await jwtVerify(cookie.value, key, { algorithms: ['HS256'] });
return NextResponse.next({
headers: {
'X-Frame-Options': 'DENY',
},
});
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
View File
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+20
View File
@@ -0,0 +1,20 @@
export const ORTE = ['Kunde FFM', 'Homeoffice', 'Andrena'] as const;
export type Ort = (typeof ORTE)[number];
// Fixe Arbeitszeit beim Kunden (Stunden) — feste Spalte in der Liste.
export const ARBEITSZEIT_KUNDE_FIX = 8;
export interface ArbeitszeitEintrag {
id: number;
datum: string; // YYYY-MM-DD
ort: Ort;
beginn: string; // HH:MM
ende: string; // HH:MM
reisezeit: number; // dezimale Stunden
vertriebsunterstuetzung: number; // dezimale Stunden
kommentar: string;
created_at: string;
}
// Eingabe-/Update-Nutzlast (ohne id/created_at)
export type ArbeitszeitInput = Omit<ArbeitszeitEintrag, 'id' | 'created_at'>;