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