Reisezeit aus Start/Ende, Pause pro Eintrag in Minuten

- Reisezeit wird aus Reise-Beginn/-Ende zur Laufzeit berechnet (Spalte reisezeit entfernt),
  Anzeige in Vorschau und Liste wie Mehrarbeit
- Pause pro Eintrag in Minuten (Feld zwischen Beginn und Ende), Default aus globaler Einstellung
- Eingabefelder werden nach dem Speichern eines neuen Eintrags geleert
- DB-Migrationen für reise_beginn/reise_ende/pause-Spalten
- Version 0.1.2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:00:57 +02:00
parent 74eedf7c3d
commit cb168fbd18
10 changed files with 147 additions and 66 deletions
+9 -3
View File
@@ -48,10 +48,16 @@ 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).
(je HH:MM), `reise_beginn`, `reise_ende` (je HH:MM, optional), `pause` (dezimale Stunden),
`vertriebsunterstuetzung` (default 2), `kommentar` (max. 500).
- `Arbeitszeit = Ende Beginn` (dezimale Stunden)
- `Reisezeit = Reise-Ende Reise-Beginn`: im Formular werden Start/Ende eingegeben, die
Reisezeit wird wie Mehrarbeit zur Laufzeit berechnet (nicht gespeichert) und in Vorschau
und Liste angezeigt. Beim Speichern koppelt sie nur die Vertriebsunterstützung.
- `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.
- Die **Pause** wird pro Eintrag gespeichert (Spalte `pause`, dezimale Stunden; Eingabe im
Formular in Minuten zwischen Beginn und Ende). Standard ist die globale Pause-Einstellung
in der `settings`-Tabelle (Tab „Einstellungen"). Mehrarbeit wird je Eintrag aus dieser
Pause zur Laufzeit berechnet, nicht gespeichert.
+1 -2
View File
@@ -121,7 +121,6 @@ export default function MainClient({ username }: Props) {
</button>
</div>
<ArbeitszeitList
pause={pause}
refreshKey={refreshKey}
onEdit={handleEdit}
limit={10}
@@ -135,7 +134,7 @@ export default function MainClient({ username }: Props) {
{/* 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} />
<ArbeitszeitList refreshKey={refreshKey} onEdit={handleEdit} limit={100} />
</div>
)}
+72 -21
View File
@@ -44,7 +44,12 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
const [ort, setOrt] = useState<string>(editEntry?.ort ?? ORTE[0]);
const [beginn, setBeginn] = useState(editEntry?.beginn ?? '');
const [ende, setEnde] = useState(editEntry?.ende ?? '');
const [reisezeit, setReisezeit] = useState(editEntry ? numToStr(editEntry.reisezeit) : '0');
// Pause pro Eintrag in Minuten; Standard ist die globale Einstellung (Stunden → Minuten).
const [pauseMin, setPauseMin] = useState(
String(Math.round((editEntry?.pause ?? pause) * 60))
);
const [reiseBeginn, setReiseBeginn] = useState(editEntry?.reise_beginn ?? '');
const [reiseEnde, setReiseEnde] = useState(editEntry?.reise_ende ?? '');
const [vertrieb, setVertrieb] = useState(editEntry ? numToStr(editEntry.vertriebsunterstuetzung) : '0');
const [kommentar, setKommentar] = useState(editEntry?.kommentar ?? '');
const [saving, setSaving] = useState(false);
@@ -53,21 +58,31 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
const zeiten = hatZeiten(beginn, ende);
const arbeitszeit = toDecimalHours(beginn, ende);
const mehr = mehrarbeit(arbeitszeit, pause);
const vertriebFrei = vertriebEditierbar(toNum(reisezeit));
const pauseStd = Math.max(0, toNum(pauseMin)) / 60;
const mehr = mehrarbeit(arbeitszeit, pauseStd);
const reisezeit = toDecimalHours(reiseBeginn, reiseEnde);
const reiseZeiten = hatZeiten(reiseBeginn, reiseEnde);
const vertriebFrei = vertriebEditierbar(reisezeit);
// Vertriebsunterstützung an die Reisezeit koppeln (siehe applyVertriebsregel):
// Vertriebsunterstützung an die (aus den Reise-Zeiten berechnete) Reisezeit koppeln
// (siehe applyVertriebsregel):
// =0 ⇒ 0, <2 ⇒ = Reisezeit, ≥2 ⇒ 2 beim Überschreiten der Schwelle, danach frei änderbar.
function handleReisezeit(raw: string) {
const alt = toNum(reisezeit);
const neu = toNum(raw);
setReisezeit(raw);
if (neu <= 0) setVertrieb('0');
else if (neu < 2) setVertrieb(numToStr(neu));
else if (alt < 2) setVertrieb('2'); // Schwelle 2h überschritten → Standard 2h
function koppleVertrieb(altReise: number, neuReise: number) {
if (neuReise <= 0) setVertrieb('0');
else if (neuReise < 2) setVertrieb(numToStr(neuReise));
else if (altReise < 2) setVertrieb('2'); // Schwelle 2h überschritten → Standard 2h
// neu ≥ 2 und alt ≥ 2: bestehenden (ggf. geänderten) Wert beibehalten
}
function handleReiseBeginn(raw: string) {
koppleVertrieb(reisezeit, toDecimalHours(raw, reiseEnde));
setReiseBeginn(raw);
}
function handleReiseEnde(raw: string) {
koppleVertrieb(reisezeit, toDecimalHours(reiseBeginn, raw));
setReiseEnde(raw);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
@@ -84,6 +99,17 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
return;
}
const reiseBeginnLeer = reiseBeginn === '';
const reiseEndeLeer = reiseEnde === '';
if (reiseBeginnLeer !== reiseEndeLeer) {
setError('Bitte Reise-Start und -Ende gemeinsam angeben oder beide leer lassen.');
return;
}
if (!reiseBeginnLeer && !reiseEndeLeer && reisezeit <= 0) {
setError('Reise-Ende muss nach dem Reise-Start liegen.');
return;
}
setSaving(true);
const body = {
@@ -91,7 +117,9 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
ort,
beginn,
ende,
reisezeit: toNum(reisezeit),
reise_beginn: reiseBeginn,
reise_ende: reiseEnde,
pause: pauseStd,
vertriebsunterstuetzung: toNum(vertrieb),
kommentar,
};
@@ -111,6 +139,18 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
}
setSuccess(true);
setTimeout(() => setSuccess(false), 4000);
// Beim Anlegen die Eingabefelder wieder auf die Startwerte zurücksetzen.
if (!editEntry) {
setDatum(todayDate());
setOrt(ORTE[0]);
setBeginn('');
setEnde('');
setPauseMin(String(Math.round(pause * 60)));
setReiseBeginn('');
setReiseEnde('');
setVertrieb('0');
setKommentar('');
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern.');
@@ -160,19 +200,27 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
<label className={labelCls}>Beginn</label>
<TimeInput value={beginn} onChange={setBeginn} className="w-24" />
</div>
<div className="shrink-0">
<label className={labelCls}>Pause (min)</label>
<input
type="text"
inputMode="numeric"
value={pauseMin}
onChange={(e) => setPauseMin(e.target.value.replace(/[^0-9]/g, ''))}
className={numCls}
/>
</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) => handleReisezeit(e.target.value)}
className={numCls}
/>
<label className={labelCls}>Reise-Beginn</label>
<TimeInput value={reiseBeginn} onChange={handleReiseBeginn} className="w-24" />
</div>
<div className="shrink-0">
<label className={labelCls}>Reise-Ende</label>
<TimeInput value={reiseEnde} onChange={handleReiseEnde} className="w-24" />
</div>
<div className="shrink-0">
<label className={labelCls}>Vertriebsunterst. (h)</label>
@@ -212,7 +260,7 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
Arbeitszeit Kunde: <strong>{zeiten ? `${formatHours(ARBEITSZEIT_KUNDE_FIX)} h` : '—'}</strong>
</span>
<span>
Pause: <strong>{formatHours(pause)} h</strong>
Pause: <strong>{formatHours(pauseStd)} h</strong>
</span>
<span>
Mehrarbeit:{' '}
@@ -220,6 +268,9 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }:
{zeiten ? `${formatHours(mehr)} h` : '—'}
</strong>
</span>
<span>
Reisezeit: <strong>{reiseZeiten ? `${formatHours(reisezeit)} h` : '—'}</strong>
</span>
</div>
{error && (
+4 -4
View File
@@ -5,7 +5,6 @@ import { ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitsz
import { toDecimalHours, mehrarbeit, weekdayName, formatHours, hatZeiten } from '@/lib/calc';
interface Props {
pause: number;
refreshKey: number;
onEdit: (entry: ArbeitszeitEintrag) => void;
limit?: number;
@@ -36,7 +35,7 @@ function formatDate(d: string) {
return m ? `${m[3]}.${m[2]}.${m[1]}` : d;
}
export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15, compact = false }: Props) {
export default function ArbeitszeitList({ 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);
@@ -132,7 +131,8 @@ export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15,
</tr>
) : entries.map((e) => {
const zeiten = hatZeiten(e.beginn, e.ende);
const mehr = mehrarbeit(toDecimalHours(e.beginn, e.ende), pause);
const mehr = mehrarbeit(toDecimalHours(e.beginn, e.ende), e.pause);
const reisezeit = toDecimalHours(e.reise_beginn, e.reise_ende);
return (
<tr key={e.id} className="hover:bg-gray-50">
<td className={`${cell} whitespace-nowrap`}>{formatDate(e.datum)}</td>
@@ -140,7 +140,7 @@ export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15,
<td className={cell}>{e.ort}</td>
<td className={`${cell} text-right`}>{zeiten ? formatHours(ARBEITSZEIT_KUNDE_FIX) : '—'}</td>
<td className={`${cell} text-right ${zeiten && mehr < 0 ? 'text-red-600' : ''}`}>{zeiten ? formatHours(mehr) : '—'}</td>
<td className={`${cell} text-right`}>{e.reisezeit ? formatHours(e.reisezeit) : '—'}</td>
<td className={`${cell} text-right`}>{reisezeit ? formatHours(reisezeit) : '—'}</td>
<td className={`${cell} text-right`}>{e.vertriebsunterstuetzung ? formatHours(e.vertriebsunterstuetzung) : '—'}</td>
<td className={cell}>{e.kommentar}</td>
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
+29 -1
View File
@@ -25,7 +25,9 @@ export function getDb(): Database.Database {
ort TEXT NOT NULL,
beginn TEXT NOT NULL,
ende TEXT NOT NULL,
reisezeit REAL NOT NULL DEFAULT 0,
reise_beginn TEXT NOT NULL DEFAULT '',
reise_ende TEXT NOT NULL DEFAULT '',
pause REAL NOT NULL DEFAULT 0.5,
vertriebsunterstuetzung REAL NOT NULL DEFAULT 2,
kommentar TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -49,11 +51,37 @@ export function getDb(): Database.Database {
);
`);
// Migration: Reise-Start/-Ende für bestehende Datenbanken ergänzen.
const cols = (db.prepare(`PRAGMA table_info(arbeitszeit)`).all() as { name: string }[]).map(
(c) => c.name
);
if (!cols.includes('reise_beginn')) {
db.exec(`ALTER TABLE arbeitszeit ADD COLUMN reise_beginn TEXT NOT NULL DEFAULT ''`);
}
if (!cols.includes('reise_ende')) {
db.exec(`ALTER TABLE arbeitszeit ADD COLUMN reise_ende TEXT NOT NULL DEFAULT ''`);
}
// Reisezeit wird zur Laufzeit aus reise_beginn/reise_ende berechnet, nicht mehr gespeichert.
if (cols.includes('reisezeit')) {
db.exec(`ALTER TABLE arbeitszeit DROP COLUMN reisezeit`);
}
// 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();
// Migration: Pause pro Eintrag ergänzen. Bestehende Zeilen bekommen die aktuelle
// globale Pause-Einstellung als Wert.
if (!cols.includes('pause')) {
const row = db.prepare(`SELECT value FROM settings WHERE key = 'pause'`).get() as
| { value: string }
| undefined;
const globalPause = row ? parseFloat(row.value) : 0.5;
const def = isNaN(globalPause) ? 0.5 : globalPause;
db.exec(`ALTER TABLE arbeitszeit ADD COLUMN pause REAL NOT NULL DEFAULT ${def}`);
}
return db;
}
+4 -3
View File
@@ -43,8 +43,8 @@ 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)`
`INSERT INTO arbeitszeit (datum, ort, beginn, ende, reise_beginn, reise_ende, pause, vertriebsunterstuetzung, kommentar)
VALUES (@datum, @ort, @beginn, @ende, @reise_beginn, @reise_ende, @pause, @vertriebsunterstuetzung, @kommentar)`
)
.run(input);
return getArbeitszeit(Number(info.lastInsertRowid))!;
@@ -58,7 +58,8 @@ export function updateArbeitszeit(
db.prepare(
`UPDATE arbeitszeit SET
datum = @datum, ort = @ort, beginn = @beginn, ende = @ende,
reisezeit = @reisezeit, vertriebsunterstuetzung = @vertriebsunterstuetzung,
reise_beginn = @reise_beginn, reise_ende = @reise_ende,
pause = @pause, vertriebsunterstuetzung = @vertriebsunterstuetzung,
kommentar = @kommentar
WHERE id = @id`
).run({ ...input, id });
+21 -4
View File
@@ -1,5 +1,6 @@
import { ORTE, type ArbeitszeitInput, type Ort } from '@/types/arbeitszeit';
import { parseHM, applyVertriebsregel } from './calc';
import { parseHM, applyVertriebsregel, toDecimalHours } from './calc';
import { getPause } from './repo';
/**
* Validiert und normalisiert einen JSON-Body zu einem ArbeitszeitInput.
@@ -24,9 +25,25 @@ export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
throw new Error('Bitte Start- und Endzeit gemeinsam angeben oder beide leer lassen.');
}
const reisezeit = toNumber(b.reisezeit, 0);
// Reise-Start/-Ende sind optional (analog Beginn/Ende). Wenn angegeben,
// müssen sie gültig sein; entweder beide leer oder beide gesetzt.
const reise_beginn = String(b.reise_beginn ?? '');
const reise_ende = String(b.reise_ende ?? '');
if (reise_beginn !== '' && parseHM(reise_beginn) === null)
throw new Error('Ungültige Reise-Startzeit.');
if (reise_ende !== '' && parseHM(reise_ende) === null)
throw new Error('Ungültige Reise-Endzeit.');
if ((reise_beginn === '') !== (reise_ende === '')) {
throw new Error('Bitte Reise-Start und -Ende gemeinsam angeben oder beide leer lassen.');
}
// Reisezeit wird zur Laufzeit aus den Reise-Zeiten berechnet (nicht gespeichert),
// dient hier aber zur Kopplung der Vertriebsunterstützung.
const reisezeit = toDecimalHours(reise_beginn, reise_ende);
// Pause pro Eintrag (dezimale Stunden). Standard ist die globale Einstellung.
const pause = toNumber(b.pause, getPause());
let vertriebsunterstuetzung = toNumber(b.vertriebsunterstuetzung, 2);
if (reisezeit < 0 || vertriebsunterstuetzung < 0) {
if (pause < 0 || vertriebsunterstuetzung < 0) {
throw new Error('Zeitwerte dürfen nicht negativ sein.');
}
// Regel: Vertriebsunterstützung an die Reisezeit koppeln.
@@ -35,7 +52,7 @@ export function parseArbeitszeitInput(body: unknown): ArbeitszeitInput {
const kommentar = String(b.kommentar ?? '').slice(0, 500);
return { datum, ort, beginn, ende, reisezeit, vertriebsunterstuetzung, kommentar };
return { datum, ort, beginn, ende, reise_beginn, reise_ende, pause, vertriebsunterstuetzung, kommentar };
}
function toNumber(value: unknown, fallback: number): number {
+3 -26
View File
@@ -1,12 +1,12 @@
{
"name": "arbeitszeit",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arbeitszeit",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.1",
@@ -282,30 +282,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -3691,6 +3667,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "arbeitszeit",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"scripts": {
"dev": "next dev",
+3 -1
View File
@@ -10,7 +10,9 @@ export interface ArbeitszeitEintrag {
ort: Ort;
beginn: string; // HH:MM
ende: string; // HH:MM
reisezeit: number; // dezimale Stunden
reise_beginn: string; // HH:MM, leer wenn keine Reise
reise_ende: string; // HH:MM, leer wenn keine Reise
pause: number; // dezimale Stunden (Eingabe im Formular in Minuten)
vertriebsunterstuetzung: number; // dezimale Stunden
kommentar: string;
created_at: string;