diff --git a/CLAUDE.md b/CLAUDE.md index 4c086d5..07443df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/MainClient.tsx b/app/MainClient.tsx index b45ccf1..786dab8 100644 --- a/app/MainClient.tsx +++ b/app/MainClient.tsx @@ -121,7 +121,6 @@ export default function MainClient({ username }: Props) { - + )} diff --git a/components/ArbeitszeitForm.tsx b/components/ArbeitszeitForm.tsx index 6dc182f..def35b3 100644 --- a/components/ArbeitszeitForm.tsx +++ b/components/ArbeitszeitForm.tsx @@ -44,7 +44,12 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }: const [ort, setOrt] = useState(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 }: +
+ + setPauseMin(e.target.value.replace(/[^0-9]/g, ''))} + className={numCls} + /> +
- - handleReisezeit(e.target.value)} - className={numCls} - /> + + +
+
+ +
@@ -212,7 +260,7 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }: Arbeitszeit Kunde: {zeiten ? `${formatHours(ARBEITSZEIT_KUNDE_FIX)} h` : '—'} - Pause: {formatHours(pause)} h + Pause: {formatHours(pauseStd)} h Mehrarbeit:{' '} @@ -220,6 +268,9 @@ export default function ArbeitszeitForm({ editEntry, pause, onSaved, onCancel }: {zeiten ? `${formatHours(mehr)} h` : '—'} + + Reisezeit: {reiseZeiten ? `${formatHours(reisezeit)} h` : '—'} +
{error && ( diff --git a/components/ArbeitszeitList.tsx b/components/ArbeitszeitList.tsx index 4e866ca..c14894d 100644 --- a/components/ArbeitszeitList.tsx +++ b/components/ArbeitszeitList.tsx @@ -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([]); const [month, setMonth] = useState(compact ? '' : currentMonth()); const [deleteId, setDeleteId] = useState(null); @@ -132,7 +131,8 @@ export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15, ) : 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 ( {formatDate(e.datum)} @@ -140,7 +140,7 @@ export default function ArbeitszeitList({ pause, refreshKey, onEdit, limit = 15, {e.ort} {zeiten ? formatHours(ARBEITSZEIT_KUNDE_FIX) : '—'} {zeiten ? formatHours(mehr) : '—'} - {e.reisezeit ? formatHours(e.reisezeit) : '—'} + {reisezeit ? formatHours(reisezeit) : '—'} {e.vertriebsunterstuetzung ? formatHours(e.vertriebsunterstuetzung) : '—'} {e.kommentar} diff --git a/lib/db.ts b/lib/db.ts index 78685a6..71728ad 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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; } diff --git a/lib/repo.ts b/lib/repo.ts index 16d26ab..7b3af69 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -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 }); diff --git a/lib/validate.ts b/lib/validate.ts index a1d0524..cee8562 100644 --- a/lib/validate.ts +++ b/lib/validate.ts @@ -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 { diff --git a/package-lock.json b/package-lock.json index 137ace5..4e17d1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2c8cc5c..eabbec7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arbeitszeit", - "version": "0.1.1", + "version": "0.1.2", "private": true, "scripts": { "dev": "next dev", diff --git a/types/arbeitszeit.ts b/types/arbeitszeit.ts index bacc775..7a8323c 100644 --- a/types/arbeitszeit.ts +++ b/types/arbeitszeit.ts @@ -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;