Files
FEDEO/frontend/pages/v2/time.vue

239 lines
22 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>