feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:"
|
||||
>
|
||||
|
||||
@@ -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:"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
238
frontend/pages/v2/time.vue
Normal 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 & 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>
|
||||
73
frontend/tests/time-v2.browser.mjs
Normal file
73
frontend/tests/time-v2.browser.mjs
Normal 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()}
|
||||
32
frontend/utils/staffTimeSpans.ts
Normal file
32
frontend/utils/staffTimeSpans.ts
Normal 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));
|
||||
}
|
||||
29
frontend/utils/timeSpanInput.ts
Normal file
29
frontend/utils/timeSpanInput.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user