441df3194e
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ORTE, ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit';
|
|
import {
|
|
toDecimalHours,
|
|
mehrarbeit,
|
|
weekdayName,
|
|
formatHours,
|
|
hatZeiten,
|
|
vertriebEditierbar,
|
|
} 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;
|
|
}
|
|
|
|
// Zahl als deutscher Dezimalstring (Komma)
|
|
function numToStr(n: number): string {
|
|
return String(n).replace('.', ',');
|
|
}
|
|
|
|
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 ?? '');
|
|
const [ende, setEnde] = useState(editEntry?.ende ?? '');
|
|
// 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);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
const zeiten = hatZeiten(beginn, ende);
|
|
const arbeitszeit = toDecimalHours(beginn, ende);
|
|
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 (aus den Reise-Zeiten berechnete) Reisezeit koppeln
|
|
// (siehe applyVertriebsregel):
|
|
// =0 ⇒ 0, <2 ⇒ = Reisezeit, ≥2 ⇒ 2 beim Überschreiten der Schwelle, danach frei änderbar.
|
|
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('');
|
|
setSuccess(false);
|
|
|
|
const beginnLeer = beginn === '';
|
|
const endeLeer = ende === '';
|
|
if (beginnLeer !== endeLeer) {
|
|
setError('Bitte Start- und Endzeit gemeinsam angeben oder beide leer lassen.');
|
|
return;
|
|
}
|
|
if (!beginnLeer && !endeLeer && arbeitszeit <= 0) {
|
|
setError('Endzeit muss nach der Startzeit liegen.');
|
|
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 = {
|
|
datum,
|
|
ort,
|
|
beginn,
|
|
ende,
|
|
reise_beginn: reiseBeginn,
|
|
reise_ende: reiseEnde,
|
|
pause: pauseStd,
|
|
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);
|
|
// 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.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const labelCls = 'block text-xs font-bold 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}>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}>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>
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={vertrieb}
|
|
onChange={(e) => setVertrieb(e.target.value)}
|
|
disabled={!vertriebFrei}
|
|
title={vertriebFrei ? undefined : 'Wird automatisch aus der Reisezeit berechnet (< 2 h)'}
|
|
className={`${numCls} disabled:bg-gray-100 disabled:text-gray-500`}
|
|
/>
|
|
</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>{zeiten ? `${formatHours(arbeitszeit)} h` : '—'}</strong>
|
|
</span>
|
|
<span>
|
|
Arbeitszeit Kunde: <strong>{zeiten ? `${formatHours(ARBEITSZEIT_KUNDE_FIX)} h` : '—'}</strong>
|
|
</span>
|
|
<span>
|
|
Pause: <strong>{formatHours(pauseStd)} h</strong>
|
|
</span>
|
|
<span>
|
|
Mehrarbeit:{' '}
|
|
<strong className={zeiten ? (mehr < 0 ? 'text-red-600' : 'text-green-800') : ''}>
|
|
{zeiten ? `${formatHours(mehr)} h` : '—'}
|
|
</strong>
|
|
</span>
|
|
<span>
|
|
Reisezeit: <strong>{reiseZeiten ? `${formatHours(reisezeit)} 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>
|
|
);
|
|
}
|