cb168fbd18
- 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>
173 lines
8.0 KiB
TypeScript
173 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { ARBEITSZEIT_KUNDE_FIX, type ArbeitszeitEintrag } from '@/types/arbeitszeit';
|
|
import { toDecimalHours, mehrarbeit, weekdayName, formatHours, hatZeiten } from '@/lib/calc';
|
|
|
|
interface Props {
|
|
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({ 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 zeiten = hatZeiten(e.beginn, e.ende);
|
|
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>
|
|
<td className={cell}>{weekdayName(e.datum)}</td>
|
|
<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`}>{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`}>
|
|
<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>
|
|
);
|
|
}
|