feat(time): add span-based attendance tracking under 2.0

This commit is contained in:
2026-10-03 15:55:45 +02:00
parent 4c67ddd908
commit 677ae0f4fb
32 changed files with 1681 additions and 47 deletions

View File

@@ -39,6 +39,8 @@ function getStateLabel(state: string): string {
}
function getTypeLabel(type: string): string {
if (type === 'pause') return 'Pause';
if (type === 'overtime_compensation') return 'Freizeitausgleich';
if (type === 'vacation') return 'Urlaub';
if (type === 'sick') return 'Krankheit';
if (type === 'holiday') return 'Feiertag';
@@ -133,6 +135,17 @@ export default function TimeTrackingScreen() {
}
}, [active, currentUserId, token, load]);
async function onPause() {
if (!token || !currentUserId || !active) return;
setActionLoading(true); setError(null);
try {
await createStaffTimeEvent(token, { eventtype: active.paused ? 'pause_end' : 'pause_start',
eventtime: new Date().toISOString(), user_id: currentUserId });
await load(false);
} catch (err) { setError(err instanceof Error ? err.message : 'Pause konnte nicht gespeichert werden.'); }
finally { setActionLoading(false); }
}
async function onSubmit(entry: StaffTimeSpan) {
if (!token || !entry.eventIds?.length) return;
setActionLoading(true);
@@ -206,10 +219,15 @@ export default function TimeTrackingScreen() {
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
{active ? `${active.paused ? 'In Pause' : 'Läuft'} seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
{active?.model === 'spans-v2' && active.type === 'work' ? (
<Pressable style={[styles.startButton, actionLoading ? styles.buttonDisabled : null]} onPress={onPause} disabled={actionLoading}>
<Text style={styles.startButtonText}>{active.paused ? 'Pause beenden' : 'Pause beginnen'}</Text>
</Pressable>
) : null}
{active ? (
<Pressable
style={[styles.stopButton, actionLoading ? styles.buttonDisabled : null]}

View File

@@ -34,6 +34,8 @@ export type TenantProfile = {
};
export type StaffTimeSpan = {
paused?: boolean;
model?: string;
id: string | null;
eventIds: string[];
state: string;
@@ -608,8 +610,22 @@ export async function fetchStaffTimeSpans(
const query = targetUserId ? `?targetUserId=${encodeURIComponent(targetUserId)}` : '';
const spans = await apiRequest<any[]>(`/api/staff/time/spans${query}`, { token });
return (spans || [])
const seen = new Set<string>();
return (spans || []).filter((span) => {
if (span.model !== 'spans-v2') return true;
if (seen.has(span.id)) return false;
seen.add(span.id); return true;
})
.map((span) => {
if (span.attendance) {
const s = span.attendance;
const end = s.ended_at ? Date.parse(s.ended_at) : Date.now();
const pauseMs = s.breaks.reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
return { id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
stopped_at: s.ended_at, duration_minutes: Math.floor((end - Date.parse(s.started_at) - pauseMs) / 60000),
user_id: s.user_id, type: s.type, description: s.description,
paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2' } as StaffTimeSpan;
}
const started = span.startedAt ? new Date(span.startedAt) : null;
const ended = span.endedAt ? new Date(span.endedAt) : new Date();
const durationMinutes =