feat(time): add span-based attendance tracking under 2.0
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user