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
+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>
);
}