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

@@ -478,6 +478,12 @@ const links = computed(() => {
icon: "i-heroicons-user-group",
children: visibleContactsChildren
}] : []),
...(featureEnabled("staffTime") ? [{
label: "2.0",
icon: "i-heroicons-sparkles",
defaultOpen: false,
children: [{ label: "Zeiterfassung", to: "/v2/time", icon: "i-heroicons-clock" }]
}] : []),
...(visibleStaffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,

View File

@@ -1,11 +1,18 @@
<script setup>
import dayjs from "dayjs";
import { useNow } from "@vueuse/core";
import { workingMinutes } from "~/utils/staffTimeSpans";
const profileStore = useProfileStore();
const toast = useToast()
const staffTime = useStaffTime()
const runningTimeInfo = ref({})
const clock = useNow({ interval: 1000 })
const workingDuration = computed(() => {
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
})
const projects = ref([])
const platform = ref("default")
@@ -51,9 +58,12 @@ const stopStartedTime = async () => {
<template>
<div v-if="runningTimeInfo.started_at">
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<p>Arbeitszeit: {{ workingDuration }}</p>
<p v-if="runningTimeInfo.paused">In Pause</p>
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
<UFormField
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Notizen:"
>
@@ -62,6 +72,7 @@ const stopStartedTime = async () => {
/>
</UFormField>
<UFormField
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Projekt:"
>

View File

@@ -1,11 +1,18 @@
<script setup>
import dayjs from "dayjs";
import { useNow } from "@vueuse/core";
import { workingMinutes } from "~/utils/staffTimeSpans";
const profileStore = useProfileStore();
const toast = useToast()
const staffTime = useStaffTime()
const runningTimeInfo = ref({})
const clock = useNow({ interval: 1000 })
const workingDuration = computed(() => {
const minutes = workingMinutes(runningTimeInfo.value, clock.value.getTime())
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
})
const setupPage = async () => {
const rows = await staffTime.list({ user_id: profileStore.activeProfile?.user_id || profileStore.activeProfile?.id })
@@ -47,9 +54,12 @@ const stopStartedTime = async () => {
<template>
<div v-if="runningTimeInfo.started_at">
<p>Start: {{dayjs(runningTimeInfo.started_at).format("HH:mm")}}</p>
<p>Dauer: {{dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') > 59 ? `${Math.floor(dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') / 60)}:${dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') % 60} h` : dayjs().diff(dayjs(runningTimeInfo.started_at),'minutes') + ' min' }}</p>
<p>Arbeitszeit: {{ workingDuration }}</p>
<p v-if="runningTimeInfo.paused">In Pause</p>
<UButton v-if="runningTimeInfo.model === 'spans-v2'" class="mt-2" variant="soft" :to="{ path: '/v2/time', query: { user_id: runningTimeInfo.user_id } }">Anwesenheit bearbeiten</UButton>
<UFormField
v-if="runningTimeInfo.model !== 'spans-v2'"
class="mt-2"
label="Notizen:"
>

View File

@@ -7,7 +7,7 @@ const setupPage = async () => {
const checks = await Promise.all(profiles.map(async (profile) => {
try {
const spans = await useNuxtApp().$api(`/api/staff/time/spans?targetUserId=${profile.user_id || profile.id}`)
const openSpan = (spans || []).find((s) => !s.endedAt && s.type === "work")
const openSpan = (spans || []).find((s) => !s.endedAt && ["work", "pause"].includes(s.type))
if (openSpan) return { profile: profile.id }
} catch (e) {
return null

View File

@@ -1,3 +1,4 @@
import { staffTimeRows } from '~/utils/staffTimeSpans'
import { defineStore } from 'pinia'
import { useAuthStore } from '~/stores/auth'
@@ -14,21 +15,7 @@ export const useStaffTime = () => {
const params = new URLSearchParams({ from, to, targetUserId })
try {
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
return (spans || []).map((span: any) => {
const start = $dayjs(span.startedAt)
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
return {
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
eventIds: span.sourceEventIds || [],
state: span.status,
started_at: span.startedAt,
stopped_at: span.endedAt,
duration_minutes: end.diff(start, 'minute'),
user_id: targetUserId,
type: span.type,
description: span.description || span.payload?.description || ''
}
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
return staffTimeRows(spans || [], targetUserId)
} catch (error) {
console.error("Fehler beim Laden:", error)
return []

View File

@@ -79,6 +79,11 @@ async function loadUsers() {
async function load() {
if (!selectedUser.value) return
const context = await useNuxtApp().$api('/api/v2/time/context')
if (context.profiles.some(p => p.user_id === selectedUser.value && p.activated_at)) {
await router.replace({ path: '/v2/time', query: { user_id: selectedUser.value } })
return
}
entries.value = await list({ user_id: selectedUser.value })
}

238
frontend/pages/v2/time.vue Normal file
View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { timeSpanInput, localTimeInput } from '~/utils/timeSpanInput';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
definePageMeta({ layout: 'default' });
const { $api } = useNuxtApp();
const auth = useAuthStore();
const route = useRoute();
const selected = ref(String(route.query.user_id || auth.user?.id || ''));
const context = ref<any>({ profiles: [], projects: [], permissions: {} });
const today = dayjs().tz('Europe/Berlin');
const from = ref(today.startOf('month').format('YYYY-MM-DD')), to = ref(today.endOf('month').format('YYYY-MM-DD'));
const tab = ref('mine'), view = ref('list'), statusFilter = ref('all');
const entries = ref<any[]>([]), active = ref<any>(null), account = ref<any>(null), evaluation = ref<any>(null);
const busy = ref(false), loading = ref(false), error = ref(''), notice = ref(''), preview = ref<any>(null);
const editorElement = ref<HTMLElement | null>(null);
const edit = ref<any>(null), history = ref<any[] | null>(null), historyEntry = ref(''), rejection = ref<any>(null), reason = ref('');
const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | undefined;
let loadSequence = 0;
const labels: Record<string, string> = { work: 'Anwesenheit', vacation: 'Urlaub', sick: 'Krankheit', overtime_compensation: 'Freizeitausgleich', holiday: 'Feiertag', other: 'Sonstiges' };
const states: Record<string, string> = { factual: 'Erfasst', submitted: 'Eingereicht', approved: 'Genehmigt', rejected: 'Abgelehnt' };
const canWrite = computed(() => selected.value === auth.user?.id || context.value.permissions.writeAll);
const paused = computed(() => active.value?.breaks?.some((p: any) => !p.ended_at));
const shown = computed(() => entries.value.filter(e => statusFilter.value === 'all' || e.status === statusFilter.value));
const groups = computed(() => {
const map = new Map<string, any[]>();
for (const entry of shown.value) {
const key = local(entry.started_at).slice(0, 10);
map.set(key, [...(map.get(key) || []), entry]);
}
return [...map.entries()];
});
const local = (value: string | null, zone = 'Europe/Berlin') => value ? localTimeInput(value, zone) : '';
const formatted = (value: string | null) => value ? new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'shortOffset' }).format(new Date(value)) : 'läuft';
const hours = (minutes: number) => { const m = Math.round(Math.abs(minutes || 0)); return `${minutes < 0 ? '−' : ''}${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`; };
function duration(e: any) {
const end = e.ended_at ? Date.parse(e.ended_at) : now.value;
return (end - Date.parse(e.started_at) - (e.breaks || []).reduce((n: number, p: any) => n + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0)) / 60000;
}
function message(e: any) { return e?.data?.error || e?.message || 'Die Aktion konnte nicht ausgeführt werden.'; }
async function loadContext() { context.value = await $api('/api/v2/time/context'); }
async function load() {
if (!selected.value)
return;
const sequence = ++loadSequence;
loading.value = true;
error.value = '';
try {
const q = new URLSearchParams({ user_id: selected.value, from: from.value, to: to.value });
const [result, report] = await Promise.all([$api(`/api/v2/time/spans?${q}`), $api(`/api/v2/time/evaluation?${q}`)]) as any[];
if (sequence !== loadSequence)
return;
entries.value = result.items;
active.value = result.active;
account.value = result.account;
evaluation.value = report.summary;
}
catch (e) {
if (sequence === loadSequence) {
error.value = message(e);
entries.value = [];
active.value = null;
account.value = null;
evaluation.value = null;
}
}
finally {
if (sequence === loadSequence)
loading.value = false;
}
}
async function perform(fn: () => Promise<any>) {
if (busy.value)
return;
busy.value = true;
error.value = '';
notice.value = '';
try {
await fn();
}
catch (e) {
error.value = message(e);
}
finally {
busy.value = false;
}
}
async function command(cmd: any) {
await $api('/api/v2/time/commands', { method: 'POST', body: { user_id: selected.value, request_id: crypto.randomUUID(), command: cmd } });
await load();
notice.value = 'Zeit gespeichert.';
}
function live(action: string) { return perform(() => command({ action, at: new Date().toISOString(), ...(action !== 'start' ? { id: active.value.id, version: active.value.version } : {}) })); }
function workflow(e: any, action: string) { return perform(() => command({ action, id: e.id, version: e.version })); }
function openEdit(e?: any) {
history.value = null;
const zone = e?.timezone || 'Europe/Berlin';
edit.value = { id: e?.id, version: e?.version, type: e?.type || 'work', description: e?.description || '', timezone: zone,
started_at: local(e?.started_at || new Date().toISOString(), zone), ended_at: local(e?.ended_at, zone), original_start: e?.started_at, original_end: e?.ended_at,
allDay: Boolean(e?.date_from), date_from: e?.date_from || today.format('YYYY-MM-DD'), date_to: e?.date_to ? dayjs.utc(e.date_to).subtract(1, 'day').format('YYYY-MM-DD') : today.format('YYYY-MM-DD'),
breaks: (e?.breaks || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })),
projects: (e?.projects || []).map((p: any) => ({ ...p, started_at: local(p.started_at, zone), ended_at: local(p.ended_at, zone), original_start: p.started_at, original_end: p.ended_at })), reason: '' };
nextTick(() => editorElement.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
function save() {
return perform(async () => {
const e = edit.value, zone = e.timezone;
const period = (p: any) => ({ started_at: timeSpanInput(p.started_at, zone, p.original_start), ended_at: p.ended_at ? timeSpanInput(p.ended_at, zone, p.original_end) : null });
const data = { type: e.type, description: e.description, timezone: zone,
...(e.allDay && e.type !== 'work' ? { date_from: e.date_from, date_to: dayjs.utc(e.date_to).add(1, 'day').format('YYYY-MM-DD') } : period(e)),
breaks: e.type === 'work' ? e.breaks.map(period) : [], projects: e.type === 'work' ? e.projects.map((p: any) => ({ ...period(p), project_id: Number(p.project_id), notes: p.notes })) : [] };
await command({ action: e.id ? 'update' : 'create', id: e.id, version: e.version, data, reason: e.reason });
edit.value = null;
});
}
function showHistory(e: any) { return perform(async () => { history.value = await $api(`/api/v2/time/spans/${e.id}/history`) as any[]; historyEntry.value = formatted(e.started_at); }); }
function migrationPreview() { return perform(async () => { preview.value = await $api(`/api/v2/time/migration?user_id=${selected.value}`); }); }
function activate() {
return perform(async () => {
await $api('/api/v2/time/activate', { method: 'POST', body: { user_id: selected.value, fingerprint: preview.value.fingerprint } });
preview.value = null;
await loadContext();
await load();
notice.value = 'Zeiterfassung 2.0 aktiviert. Alle Erfassungswege verwenden jetzt die gespeicherten Spannen.';
});
}
function segments(e: any) {
const end = e.ended_at ? Date.parse(e.ended_at) : now.value, start = Date.parse(e.started_at), length = Math.max(1, end - start);
return (e.breaks || []).map((p: any) => ({ left: `${100 * (Date.parse(p.started_at) - start) / length}%`, width: `${100 * ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)) / length}%` }));
}
watch(selected, () => { edit.value = null; preview.value = null; history.value = null; rejection.value = null; load(); });
watch(tab, value => { if (value === 'mine')
selected.value = auth.user?.id || selected.value; statusFilter.value = value === 'team' ? 'submitted' : 'all'; });
onMounted(async () => { timer = setInterval(() => { now.value = Date.now(); }, 1000); await perform(async () => { await loadContext(); await load(); }); });
onUnmounted(() => { if (timer)
clearInterval(timer); });
</script>
<template>
<UDashboardPanel id="time-v2">
<template #header><UDashboardNavbar title="Zeiterfassung 2.0" /></template>
<template #body>
<div class="mx-auto w-full max-w-6xl space-y-6 pb-12">
<div class="flex flex-wrap gap-2" role="tablist" aria-label="Zeitansichten">
<UButton :variant="tab === 'mine' ? 'solid' : 'soft'" @click="tab='mine'">Meine Zeiten</UButton>
<UButton v-if="context.permissions.readAll" :variant="tab === 'team' ? 'solid' : 'soft'" @click="tab='team'">Team &amp; Freigaben</UButton>
<UButton :variant="tab === 'evaluation' ? 'solid' : 'soft'" @click="tab='evaluation'">Auswertung</UButton>
</div>
<p v-if="error" role="alert" class="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:text-red-200">{{ error }}</p>
<p v-if="notice" role="status" class="rounded-lg bg-green-50 p-4 text-green-800 dark:bg-green-950 dark:text-green-200">{{ notice }}</p>
<form class="flex flex-wrap items-end gap-4" @submit.prevent="load">
<label v-if="context.permissions.readAll" class="field">Mitarbeiter<select v-model="selected" :disabled="busy"><option v-for="p in context.profiles" :key="p.user_id" :value="p.user_id">{{ p.full_name }}</option></select></label>
<label class="field">Von<input v-model="from" type="date" required></label>
<label class="field">Bis<input v-model="to" type="date" required></label>
<UButton type="submit" :loading="loading" :disabled="busy">Anzeigen</UButton>
</form>
<section v-if="!account && !loading" class="rounded-xl border border-amber-300 p-5 space-y-3">
<h2 class="font-semibold">Zeiterfassung 2.0 aktivieren</h2>
<p>Vor der Umstellung werden bestehende Zeiten und Projektzuordnungen geprüft. Nach der Aktivierung nutzen auch die Mobile-App und die bisherigen Zeitanzeigen dieses Modell.</p>
<UButton v-if="context.permissions.activate" :loading="busy" @click="migrationPreview">Übernahme prüfen</UButton>
<p v-else>Die Aktivierung erfolgt durch einen Administrator oder eine Person mit Aktivierungsberechtigung.</p>
<NuxtLink to="/staff/time" class="underline block">Bisherige Zeiterfassung öffnen</NuxtLink>
<div v-if="preview" class="space-y-3">
<p>{{ preview.count }} Einträge · {{ hours(preview.totalMinutes) }} Arbeitszeit zur Übernahme</p>
<ul v-if="preview.issues.length" class="list-disc pl-5"><li v-for="issue in preview.issues" :key="issue">{{ issue }}</li></ul>
<details v-if="preview.candidates.length"><summary class="cursor-pointer">Einträge prüfen</summary><ul class="max-h-64 overflow-auto text-sm"><li v-for="(c,i) in preview.candidates" :key="i">{{ labels[c.span.type] }} · {{ formatted(c.span.started_at) }} – {{ formatted(c.span.ended_at) }} · {{ states[c.span.status] }}</li></ul></details>
<UButton :disabled="preview.issues.length > 0 || busy" @click="activate">Geprüfte Daten übernehmen und aktivieren</UButton>
</div>
</section>
<template v-if="account">
<section v-if="tab !== 'evaluation'" class="rounded-xl border border-default p-5 space-y-4">
<div class="flex flex-wrap justify-between gap-4">
<div><p class="text-sm text-muted">Aktueller Status</p><h2 class="text-xl font-semibold">{{ active ? (active.type === 'work' ? paused ? 'In Pause' : 'Anwesend' : labels[active.type]) : 'Nicht anwesend' }}</h2>
<p v-if="active" class="text-sm">Seit {{ formatted(active.started_at) }} · {{ hours(duration(active)) }} {{ active.type === 'work' ? 'Arbeitszeit' : '' }}</p></div>
<div v-if="canWrite" class="flex flex-wrap items-center gap-2">
<UButton v-if="!active" :disabled="busy || loading" @click="live('start')">Kommen</UButton>
<template v-else-if="active.type === 'work'"><UButton :disabled="busy || loading" variant="soft" @click="live(paused ? 'resume' : 'pause')">{{ paused ? 'Pause beenden' : 'Pause beginnen' }}</UButton><UButton :disabled="busy || loading" @click="live('stop')">Gehen</UButton></template>
<UButton v-else :disabled="busy" @click="openEdit(active)">Eintrag abschließen</UButton>
<UButton variant="outline" :disabled="busy" @click="openEdit()">Manuell erfassen</UButton>
</div>
</div>
</section>
<section v-if="tab === 'evaluation' && evaluation" class="space-y-4">
<p class="text-sm text-muted">Abgeschlossene Zeiten · Zeitzone Europe/Berlin · Freizeitausgleich und Sonstiges erzeugen keine Zeitgutschrift.</p>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div v-for="[label,key] in [['Sollzeit','timeSpanWorkingMinutes'],['Anwesenheit','presenceMinutes'],['Pausen','breakMinutes'],['Eingereicht inkl. genehmigt','sumWorkingMinutesSubmitted'],['Genehmigte Arbeitszeit','sumWorkingMinutesApproved'],['Urlaub','sumWorkingMinutesVacationDays'],['Krankheit','sumWorkingMinutesSickDays'],['Feiertagsausgleich','sumWorkingMinutesRecreationDays'],['Freizeitausgleich','sumOvertimeCompensationMinutes'],['Sonstiges','sumOtherMinutes'],['Saldo eingereicht','saldoSubmitted'],['Saldo genehmigt','saldoApproved']]" :key="key" class="rounded-xl border border-default p-4"><p class="text-sm text-muted">{{ label }}</p><p class="text-xl font-semibold tabular-nums">{{ hours(evaluation[key]) }}</p></div>
</div>
<p>Urlaub: {{ Number(evaluation.sumVacationDays).toLocaleString('de-DE') }} Arbeitstage · Krankheit: {{ Number(evaluation.sumSickDays).toLocaleString('de-DE') }} Arbeitstage · Feiertage: {{ evaluation.sumRecreationDays }}</p>
</section>
<template v-else>
<div class="flex flex-wrap items-center justify-between gap-3"><label class="field">Status<select v-model="statusFilter"><option value="all">Alle</option><option v-for="(label,key) in states" :key="key" :value="key">{{ label }}</option></select></label><div class="flex gap-2"><UButton :variant="view === 'list' ? 'solid' : 'soft'" @click="view='list'">Liste</UButton><UButton :variant="view === 'timeline' ? 'solid' : 'soft'" @click="view='timeline'">Zeitstrahl</UButton></div></div>
<p v-if="!shown.length && !loading" class="py-8 text-center text-muted">Keine Einträge für diese Auswahl.</p>
<section v-for="[date,items] in groups" :key="date" class="space-y-3">
<h2 class="font-semibold">{{ dayjs(date).format('DD.MM.YYYY') }}</h2>
<article v-for="e in items" :key="e.id" class="rounded-xl border border-default p-4 space-y-3">
<div class="flex flex-wrap justify-between gap-3"><div><strong>{{ labels[e.type] }}</strong><span class="ml-3 text-sm">{{ states[e.status] }}</span><p class="text-sm">{{ formatted(e.started_at) }} – {{ formatted(e.ended_at) }}</p><p v-if="e.description" class="whitespace-pre-wrap text-sm text-muted">{{ e.description }}</p></div><strong class="tabular-nums">{{ hours(duration(e)) }}</strong></div>
<div v-if="view === 'timeline'" class="space-y-1"><div class="relative h-7 overflow-hidden rounded bg-primary/70" role="img" :aria-label="`${labels[e.type]} mit ${e.breaks.length} Pausen`"><span v-for="(style,i) in segments(e)" :key="i" :style="style" class="absolute top-0 h-full bg-amber-400" /></div><p class="text-xs text-muted">Grün: Anwesenheit · Gelb: Pause · Skala je Eintrag</p></div>
<p v-for="(p,i) in e.breaks" :key="i" class="text-sm">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p>
<p v-for="p in e.projects" :key="p.id" class="text-sm">Projekt {{ context.projects.find((x:any) => Number(x.id) === Number(p.project_id))?.name || p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p>
<div class="flex flex-wrap gap-2"><UButton v-if="canWrite" size="sm" variant="soft" :disabled="busy" @click="openEdit(e)">Bearbeiten</UButton><UButton v-if="canWrite && e.ended_at && ['factual','rejected'].includes(e.status)" size="sm" :disabled="busy" @click="workflow(e,'submit')">Einreichen</UButton><template v-if="context.permissions.approve && e.status === 'submitted'"><UButton size="sm" :disabled="busy" @click="workflow(e,'approve')">Genehmigen</UButton><UButton size="sm" color="error" variant="soft" :disabled="busy" @click="rejection=e; reason=''">Ablehnen</UButton></template><UButton size="sm" variant="ghost" :disabled="busy" @click="showHistory(e)">Verlauf</UButton></div>
</article>
</section>
</template>
</template>
<section v-if="rejection" class="rounded-xl border border-default p-5 space-y-3"><h2 class="font-semibold">Eintrag ablehnen</h2><label class="field">Begründung<textarea v-model="reason" /></label><div class="flex gap-2"><UButton :disabled="!reason.trim() || busy" @click="perform(async () => { await command({action:'reject',id:rejection.id,version:rejection.version,reason}); rejection=null })">Ablehnung speichern</UButton><UButton variant="ghost" @click="rejection=null">Abbrechen</UButton></div></section>
<section v-if="edit" ref="editorElement" class="rounded-xl border-2 border-primary p-5 space-y-4">
<h2 class="text-lg font-semibold">{{ edit.id ? 'Eintrag bearbeiten' : 'Zeit erfassen' }}</h2>
<p v-if="edit.id" class="text-sm">Änderungen setzen eine bestehende Einreichung oder Genehmigung zurück.</p>
<form class="space-y-4" @submit.prevent="save">
<div class="grid gap-4 md:grid-cols-2"><label class="field">Art<select v-model="edit.type"><option v-for="(label,key) in labels" :key="key" :value="key">{{ label }}</option></select></label><label class="field">Zeitzone<input v-model="edit.timezone" required placeholder="Europe/Berlin"></label></div>
<label v-if="edit.type !== 'work'" class="flex items-center gap-2"><input v-model="edit.allDay" type="checkbox">Ganztägig</label>
<div v-if="edit.allDay && edit.type !== 'work'" class="grid gap-4 md:grid-cols-2"><label class="field">Erster Tag<input v-model="edit.date_from" type="date" required></label><label class="field">Letzter Tag (einschließlich)<input v-model="edit.date_to" type="date" :min="edit.date_from" required></label></div>
<div v-else class="grid gap-4 md:grid-cols-2"><label class="field">Beginn<input v-model="edit.started_at" type="datetime-local" required></label><label class="field">Ende (leer = läuft)<input v-model="edit.ended_at" type="datetime-local"></label></div>
<label class="field">Beschreibung<textarea v-model="edit.description" maxlength="10000" /></label>
<template v-if="edit.type === 'work'">
<fieldset class="space-y-3"><legend class="font-semibold">Pausen</legend><div v-for="(p,i) in edit.breaks" :key="i" class="flex flex-wrap items-end gap-3"><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local"></label><UButton variant="ghost" color="error" @click="edit.breaks.splice(i,1)">Entfernen</UButton></div><UButton variant="soft" @click="edit.breaks.push({started_at:edit.started_at,ended_at:edit.ended_at})">Pause hinzufügen</UButton></fieldset>
<fieldset class="space-y-3"><legend class="font-semibold">Projektzuordnungen</legend><div v-for="(p,i) in edit.projects" :key="i" class="grid gap-3 rounded-lg border border-default p-3 md:grid-cols-2"><label class="field">Projekt<select v-model="p.project_id" required><option value="">Bitte wählen</option><option v-for="project in context.projects" :key="project.id" :value="project.id">{{ project.name }}</option></select></label><label class="field">Notiz<input v-model="p.notes"></label><label class="field">Beginn<input v-model="p.started_at" type="datetime-local" required></label><label class="field">Ende<input v-model="p.ended_at" type="datetime-local" required></label><UButton variant="ghost" color="error" @click="edit.projects.splice(i,1)">Zuordnung entfernen</UButton></div><UButton variant="soft" @click="edit.projects.push({project_id:'',started_at:edit.started_at,ended_at:edit.ended_at,notes:''})">Projekt hinzufügen</UButton></fieldset>
</template>
<label v-if="edit.id" class="field">Korrekturgrund<input v-model="edit.reason" required></label>
<div class="flex gap-2"><UButton type="submit" :loading="busy">Speichern</UButton><UButton variant="ghost" :disabled="busy" @click="edit=null">Abbrechen</UButton></div>
</form>
</section>
<section v-if="history" class="rounded-xl border border-default p-5 space-y-3"><div class="flex justify-between"><h2 class="font-semibold">Verlauf · {{ historyEntry }}</h2><UButton variant="ghost" @click="history=null">Schließen</UButton></div><details v-for="h in history" :key="h.id" class="border-b border-default py-2"><summary class="cursor-pointer">{{ formatted(h.created_at) }} · {{ ({create:'Erstellt',start:'Gekommen',stop:'Gegangen',pause:'Pause begonnen',resume:'Pause beendet',update:'Korrigiert',submit:'Eingereicht',approve:'Genehmigt',reject:'Abgelehnt',migration:'Übernommen'} as any)[h.action] || h.action }} · {{ context.profiles.find((p:any) => p.user_id === h.actor_id)?.full_name || h.actor_id }}</summary><p class="py-2">{{ h.reason }}</p><div class="grid gap-4 md:grid-cols-2"><div v-for="[label,snapshot] in [['Vorher',h.before_data],['Nachher',h.after_data]]" :key="label" class="text-sm"><strong>{{ label }}</strong><template v-if="snapshot"><p>{{ labels[snapshot.type] }} · {{ states[snapshot.status] }}</p><p>{{ formatted(snapshot.started_at) }} – {{ formatted(snapshot.ended_at) }}</p><p>{{ snapshot.description }}</p><p v-for="(p,i) in snapshot.breaks" :key="i">Pause: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }}</p><p v-for="(p,i) in snapshot.projects" :key="i">Projekt {{ p.project_id }}: {{ formatted(p.started_at) }} – {{ formatted(p.ended_at) }} · {{ p.notes }}</p></template><p v-else>—</p></div></div></details></section>
</div>
</template>
</UDashboardPanel>
</template>
<style scoped>
.field { display: flex; flex-direction: column; gap: .35rem; font-size: .875rem; }
.field input, .field select, .field textarea { border: 1px solid var(--ui-border, #cbd5e1); border-radius: .5rem; padding: .5rem .75rem; background: var(--ui-bg, white); color: inherit; min-width: 0; }
.field textarea { min-height: 5rem; }
.field input:focus, .field select:focus, .field textarea:focus { outline: 2px solid var(--ui-primary, #69c350); outline-offset: 2px; }
</style>

View File

@@ -0,0 +1,73 @@
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
// All API calls are isolated fixtures; no real account or application data is used.
import assert from 'node:assert/strict'
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
const base = process.env.TIME_V2_FRONTEND_URL || 'http://127.0.0.1:3148'
const browser = await chromium.launch({headless:true})
const page = await browser.newPage({viewport:{width:1440,height:1100}})
const errors = []
page.on('pageerror',e => errors.push(e.message))
const user = '11111111-1111-4111-8111-111111111111'
const month = new Date().toISOString().slice(0,7)
const start = `${month}-01T06:00:00.000Z`
let item = {id:'22222222-2222-4222-8222-222222222222',user_id:user,type:'work',description:'Test Anwesenheit',started_at:start,
ended_at:`${month}-01T14:30:00.000Z`,date_from:null,date_to:null,timezone:'Europe/Berlin',status:'factual',version:1,
breaks:[{started_at:`${month}-01T10:00:00.000Z`,ended_at:`${month}-01T10:30:00.000Z`}],projects:[]}
const profile={id:user,user_id:user,full_name:'Anna Beispiel',first_name:'Anna',last_name:'Beispiel',pinned_on_navigation:[],temp_config:{},role:{rights:[]}}
const tenant={id:1,name:'Testbetrieb',extraModules:[],features:{},locked:'none'}
const commands=[]
await page.addInitScript(() => localStorage.setItem('token','fixture-only'))
await page.route('**/*',async route => {
const url=new URL(route.request().url())
if(url.origin!==new URL(base).origin) return route.abort()
if(!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
let body=[]
if(url.pathname==='/api/me') body={user:{id:user,email:'anna@example.test',is_admin:true},profile,permissions:[],tenants:[tenant],activeTenant:1}
else if(url.pathname==='/api/tenant') body=tenant
else if(url.pathname==='/api/tenant/profiles') body={data:[profile]}
else if(url.pathname==='/api/v2/time/context') body={profiles:[{...profile,activated_at:start}],projects:[{id:1,name:'Projekt Nord'}],permissions:{readAll:true,writeAll:true,approve:true,activate:true}}
else if(url.pathname==='/api/v2/time/spans') body={items:[item],active:item.ended_at ? null : item,account:{activated_at:start}}
else if(url.pathname==='/api/v2/time/evaluation') body={summary:{timeSpanWorkingMinutes:480,presenceMinutes:510,breakMinutes:30,sumWorkingMinutesApproved:480,sumWorkingMinutesSubmitted:480,saldoApproved:0,saldoSubmitted:0,sumVacationDays:0,sumSickDays:0,sumRecreationDays:0}}
else if(url.pathname==='/api/v2/time/commands') {
const data=route.request().postDataJSON();commands.push(data)
const c=data.command
if(c.action==='update') item={...item,...c.data,status:'factual',version:item.version+1}
else if(c.action==='submit') item={...item,status:'submitted',version:item.version+1}
else if(c.action==='approve') item={...item,status:'approved',version:item.version+1}
body=item
}
else if(url.pathname.endsWith('/history')) body=[{id:'history-1',span_id:item.id,actor_id:user,action:'create',reason:'',before_data:null,after_data:item,created_at:start}]
return route.fulfill({contentType:'application/json',body:JSON.stringify(body)})
})
try {
await page.goto(`${base}/v2/time`,{waitUntil:'networkidle'})
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).waitFor()
await page.getByRole('button',{name:'Zeitstrahl',exact:true}).click()
await page.getByRole('img',{name:'Anwesenheit mit 1 Pausen'}).waitFor()
await page.getByRole('button',{name:'Bearbeiten',exact:true}).click()
await page.getByLabel('Beschreibung',{exact:true}).fill('Korrigierte Anwesenheit')
await page.getByLabel('Korrekturgrund',{exact:true}).fill('Notiz korrigiert')
await page.getByRole('button',{name:'Speichern',exact:true}).click()
await page.getByText('Zeit gespeichert.',{exact:true}).waitFor()
assert.equal(commands.at(-1).command.data.breaks.length,1)
assert.equal(commands.at(-1).command.version,1)
assert.equal(commands.at(-1).command.reason,'Notiz korrigiert')
await page.getByRole('button',{name:'Einreichen',exact:true}).click()
await page.getByRole('button',{name:'Genehmigen',exact:true}).click()
await page.getByRole('button',{name:'Verlauf',exact:true}).click()
await page.getByText('Verlauf ·',{exact:false}).waitFor()
await page.getByRole('button',{name:'Schließen',exact:true}).click()
await page.getByRole('button',{name:'Auswertung',exact:true}).click()
await page.getByText('Saldo genehmigt',{exact:true}).waitFor()
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_DESKTOP || '/tmp/fedeo-time-v2-desktop.png',fullPage:true})
await page.setViewportSize({width:390,height:844})
await page.getByRole('button',{name:'Meine Zeiten',exact:true}).click()
await page.getByRole('button',{name:'Manuell erfassen',exact:true}).click()
await page.getByLabel(/^Art/).selectOption('vacation')
await page.getByLabel('Ganztägig',{exact:true}).check()
await page.getByLabel('Erster Tag',{exact:true}).waitFor()
await page.screenshot({path:process.env.TIME_V2_SCREENSHOT_MOBILE || '/tmp/fedeo-time-v2-mobile.png',fullPage:true})
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth>window.innerWidth),false,'Mobile layout overflows')
assert.deepEqual(errors,[],'Browser runtime errors')
console.log('Time 2.0 browser checks passed: editing with pauses, submission, approval, history, evaluation, mobile absence form.')
} catch (error) { await page.screenshot({path:'/tmp/fedeo-time-v2-error.png',fullPage:true}); throw error } finally {await browser.close()}

View File

@@ -0,0 +1,32 @@
/** Collapse legacy-compatible work segments into complete persisted attendances for widgets. */
export function staffTimeRows(spans: any[], userId: string, now = Date.now()) {
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 => {
const s = span.attendance;
if (s) {
const entry = {
id: s.id, eventIds: [s.id], state: s.status, started_at: s.started_at,
stopped_at: s.ended_at, user_id: s.user_id, type: s.type, description: s.description,
breaks: s.breaks, paused: s.breaks.some((p: any) => !p.ended_at), model: 'spans-v2',
};
return { ...entry, duration_minutes: workingMinutes(entry, now) };
}
return {
id: span.sourceEventIds?.[0] || null, eventIds: span.sourceEventIds || [], state: span.status,
started_at: span.startedAt, stopped_at: span.endedAt, user_id: userId, type: span.type,
description: span.description || span.payload?.description || '',
duration_minutes: Math.floor(((span.endedAt ? Date.parse(span.endedAt) : now) - Date.parse(span.startedAt)) / 60000),
};
}).sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
}
export function workingMinutes(entry: any, now = Date.now()) {
if (!entry.started_at) return 0;
const end = entry.stopped_at ? Date.parse(entry.stopped_at) : now;
const pauses = (entry.breaks || []).reduce((total: number, p: any) => total + ((p.ended_at ? Date.parse(p.ended_at) : end) - Date.parse(p.started_at)), 0);
return Math.max(0, Math.floor((end - Date.parse(entry.started_at) - pauses) / 60000));
}

View File

@@ -0,0 +1,29 @@
/** Use Intl directly: converting repeated hours through a local Date loses their UTC offset. */
export function localTimeInput(value: string, zone = 'Europe/Berlin') {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date(value));
const get = (type: string) => parts.find(part => part.type === type)!.value;
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`;
}
/** Preserve existing instants. Require an explicit UTC input for ambiguous new local times. */
export function timeSpanInput(value: string, zone: string, original?: string | null) {
if (original && localTimeInput(original, zone) === value) return original;
const wall = Date.parse(`${value}:00Z`);
if (!Number.isFinite(wall)) throw new Error('Ungültiger Zeitpunkt.');
// Sample both sides of a transition; supports non-hour offsets and 30-minute DST changes.
const offsets = new Set<number>();
for (let hours = -36; hours <= 36; hours += 6) {
const sample = wall + hours * 3600000;
offsets.add(Date.parse(`${localTimeInput(new Date(sample).toISOString(), zone)}:00Z`) - sample);
}
const matches = [...offsets].map(offset => wall - offset)
.filter(candidate => localTimeInput(new Date(candidate).toISOString(), zone) === value);
if (!matches.length) throw new Error('Diese Ortszeit existiert nicht. Bitte Zeitumstellung beachten.');
if (matches.length > 1) {
throw new Error('Diese Ortszeit kommt zweimal vor. Bitte Zeitzone UTC wählen und den eindeutigen UTC-Zeitpunkt eingeben.');
}
return new Date(matches[0]).toISOString();
}