Merge branch 'devCorrected' into 'beta'
Time Changes See merge request fedeo/software!51
This commit is contained in:
@@ -1,206 +1,170 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from "dayjs";
|
import { z } from 'zod'
|
||||||
|
import type { FormSubmitEvent } from '#ui/types'
|
||||||
|
import { useStaffTime } from '~/composables/useStaffTime'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps({
|
||||||
modelValue: boolean;
|
modelValue: { type: Boolean, default: false },
|
||||||
entry?: any | null;
|
entry: { type: Object, default: null },
|
||||||
users: any[];
|
defaultUserId: { type: String, default: null }
|
||||||
canSelectUser: boolean;
|
})
|
||||||
defaultUserId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||||
|
|
||||||
const { create, update } = useStaffTime();
|
// 💡 createEntry importieren
|
||||||
|
const { update, createEntry } = useStaffTime()
|
||||||
|
const { $dayjs } = useNuxtApp()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const show = computed({
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const types = [
|
||||||
|
{ label: 'Arbeitszeit', value: 'work' },
|
||||||
|
{ label: 'Pause', value: 'pause' },
|
||||||
|
{ label: 'Urlaub', value: 'vacation' },
|
||||||
|
{ label: 'Krankheit', value: 'sick' },
|
||||||
|
{ label: 'Feiertag', value: 'holiday' },
|
||||||
|
{ label: 'Sonstiges', value: 'other' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
start_date: '',
|
||||||
|
start_time: '',
|
||||||
|
end_date: '',
|
||||||
|
end_time: '',
|
||||||
|
type: 'work',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
start_date: z.string().min(1, 'Datum erforderlich'),
|
||||||
|
start_time: z.string().min(1, 'Zeit erforderlich'),
|
||||||
|
type: z.string(),
|
||||||
|
description: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (v: boolean) => emit("update:modelValue", v),
|
set: (value) => emit('update:modelValue', value)
|
||||||
});
|
})
|
||||||
|
|
||||||
// 🌈 Typen
|
const toDateStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('YYYY-MM-DD') : ''
|
||||||
const typeOptions = [
|
const toTimeStr = (dateStr: string) => dateStr ? $dayjs(dateStr).format('HH:mm') : ''
|
||||||
{ label: "Arbeitszeit", value: "work" },
|
|
||||||
{ label: "Urlaub", value: "vacation" },
|
|
||||||
{ label: "Krankheit", value: "sick" },
|
|
||||||
{ label: "Feiertag", value: "holiday" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Lokaler State
|
watch(() => props.entry, (newVal) => {
|
||||||
const local = reactive({
|
if (newVal) {
|
||||||
id: "",
|
// EDIT
|
||||||
user_id: "", // 👈 Mitarbeiter
|
state.start_date = toDateStr(newVal.started_at)
|
||||||
description: "",
|
state.start_time = toTimeStr(newVal.started_at)
|
||||||
started_at: "",
|
state.end_date = newVal.stopped_at ? toDateStr(newVal.stopped_at) : ''
|
||||||
stopped_at: "",
|
state.end_time = newVal.stopped_at ? toTimeStr(newVal.stopped_at) : ''
|
||||||
type: "work",
|
state.type = newVal.type || 'work'
|
||||||
vacation_reason: "",
|
state.description = newVal.description || ''
|
||||||
sick_reason: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 📡 ENTRY —> LOCAL
|
|
||||||
watch(
|
|
||||||
() => props.entry,
|
|
||||||
(val) => {
|
|
||||||
if (val) {
|
|
||||||
Object.assign(local, {
|
|
||||||
id: val.id,
|
|
||||||
user_id: val.user_id, // 👈 Mitarbeiter vorbelegen
|
|
||||||
description: val.description || "",
|
|
||||||
type: val.type || "work",
|
|
||||||
|
|
||||||
started_at:
|
|
||||||
val.type === "vacation"
|
|
||||||
? dayjs(val.started_at).format("YYYY-MM-DD")
|
|
||||||
: dayjs(val.started_at).format("YYYY-MM-DDTHH:mm"),
|
|
||||||
|
|
||||||
stopped_at:
|
|
||||||
val.type === "vacation"
|
|
||||||
? dayjs(val.stopped_at).format("YYYY-MM-DD")
|
|
||||||
: dayjs(val.stopped_at).format("YYYY-MM-DDTHH:mm"),
|
|
||||||
|
|
||||||
vacation_reason: val.vacation_reason || "",
|
|
||||||
sick_reason: val.sick_reason || "",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
Object.assign(local, {
|
// CREATE (Standardwerte: Heute)
|
||||||
id: "",
|
const now = $dayjs()
|
||||||
user_id: props.defaultUserId, // 👈 Neuer Eintrag → aktueller Nutzer
|
state.start_date = now.format('YYYY-MM-DD')
|
||||||
description: "",
|
state.start_time = now.format('HH:mm')
|
||||||
type: "work",
|
state.end_date = ''
|
||||||
started_at: dayjs().format("YYYY-MM-DDTHH:mm"),
|
state.end_time = ''
|
||||||
stopped_at: dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm"),
|
state.type = 'work'
|
||||||
vacation_reason: "",
|
state.description = ''
|
||||||
sick_reason: "",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
}, { immediate: true })
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const loading = ref(false);
|
async function onSubmit(event: FormSubmitEvent<any>) {
|
||||||
|
loading.value = true
|
||||||
async function handleSubmit() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
try {
|
||||||
const payload: any = {
|
// 1. Datum und Zeit kombinieren
|
||||||
user_id: local.user_id, // 👈 immer senden
|
const startIso = $dayjs(`${state.start_date} ${state.start_time}`).toISOString()
|
||||||
type: local.type,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (local.type === "vacation") {
|
let endIso = null
|
||||||
payload.started_at = dayjs(local.started_at).startOf("day").toISOString();
|
if (state.end_date && state.end_time) {
|
||||||
payload.stopped_at = dayjs(local.stopped_at).endOf("day").toISOString();
|
endIso = $dayjs(`${state.end_date} ${state.end_time}`).toISOString()
|
||||||
payload.vacation_reason = local.vacation_reason;
|
|
||||||
|
if ($dayjs(endIso).isBefore($dayjs(startIso))) {
|
||||||
|
throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.entry) {
|
||||||
|
// 🟢 UPDATE (Bearbeiten)
|
||||||
|
await update(props.entry, {
|
||||||
|
start: startIso,
|
||||||
|
end: endIso,
|
||||||
|
type: state.type,
|
||||||
|
description: state.description
|
||||||
|
})
|
||||||
|
toast.add({ title: 'Eintrag aktualisiert', color: 'green' })
|
||||||
} else {
|
} else {
|
||||||
payload.started_at = dayjs(local.started_at).toISOString();
|
// 🟢 CREATE (Neu Erstellen)
|
||||||
payload.stopped_at = local.stopped_at
|
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
|
||||||
? dayjs(local.stopped_at).toISOString()
|
await createEntry({
|
||||||
: null;
|
start: startIso, // Die eingegebene Startzeit
|
||||||
payload.description = local.description;
|
end: endIso, // Die eingegebene Endzeit (oder null)
|
||||||
|
type: state.type,
|
||||||
|
description: state.description
|
||||||
|
})
|
||||||
|
|
||||||
if (local.type === "sick") {
|
toast.add({ title: 'Zeit manuell erfasst', color: 'green' })
|
||||||
payload.sick_reason = local.sick_reason;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (local.id) {
|
emit('saved')
|
||||||
await update(local.id, payload);
|
isOpen.value = false
|
||||||
} else {
|
} catch (error: any) {
|
||||||
await create(payload);
|
toast.add({ title: 'Fehler', description: error.message, color: 'red' })
|
||||||
}
|
|
||||||
|
|
||||||
emit("saved");
|
|
||||||
show.value = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UModal v-model="show" :ui="{ width: 'w-full sm:max-w-md' }" :key="local.id || 'new'">
|
<UModal v-model="isOpen">
|
||||||
<UCard>
|
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="font-semibold text-lg">
|
<div class="flex items-center justify-between">
|
||||||
{{ local.id ? "Zeit bearbeiten" : "Neue Zeit erfassen" }}
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||||
|
{{ entry ? 'Eintrag bearbeiten' : 'Neue Zeit erfassen' }}
|
||||||
|
</h3>
|
||||||
|
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UForm @submit.prevent="handleSubmit" class="space-y-4">
|
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||||
|
|
||||||
<!-- 👥 Mitarbeiter-Auswahl -->
|
<UFormGroup label="Typ" name="type">
|
||||||
<UFormGroup label="Mitarbeiter" v-if="props.canSelectUser">
|
<USelectMenu v-model="state.type" :options="types" value-attribute="value" option-attribute="label" />
|
||||||
<USelectMenu
|
|
||||||
v-model="local.user_id"
|
|
||||||
:options="props.users.map(u => ({
|
|
||||||
label: u.full_name || u.email,
|
|
||||||
value: u.user_id
|
|
||||||
}))"
|
|
||||||
placeholder="Mitarbeiter wählen"
|
|
||||||
option-attribute="label"
|
|
||||||
value-attribute="value"
|
|
||||||
/>
|
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<!-- TYPE -->
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<UFormGroup label="Typ">
|
<UFormGroup label="Start Datum" name="start_date">
|
||||||
<USelect v-model="local.type" :options="typeOptions" />
|
<UInput type="date" v-model="state.start_date" />
|
||||||
|
</UFormGroup>
|
||||||
|
<UFormGroup label="Start Zeit" name="start_time">
|
||||||
|
<UInput type="time" v-model="state.start_time" />
|
||||||
|
</UFormGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<UFormGroup label="Ende Datum" name="end_date">
|
||||||
|
<UInput type="date" v-model="state.end_date" />
|
||||||
|
</UFormGroup>
|
||||||
|
<UFormGroup label="Ende Zeit" name="end_time">
|
||||||
|
<UInput type="time" v-model="state.end_time" />
|
||||||
|
</UFormGroup>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 -mt-2">Leer lassen, wenn die Zeit noch läuft.</p>
|
||||||
|
|
||||||
|
<UFormGroup label="Beschreibung / Notiz" name="description">
|
||||||
|
<UTextarea v-model="state.description" placeholder="Was wurde gemacht?" />
|
||||||
</UFormGroup>
|
</UFormGroup>
|
||||||
|
|
||||||
<!-- VACATION -->
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
<template v-if="local.type === 'vacation'">
|
<UButton label="Abbrechen" color="gray" variant="ghost" @click="isOpen = false" />
|
||||||
<UFormGroup label="Urlaubsgrund">
|
<UButton type="submit" label="Speichern" color="primary" :loading="loading" />
|
||||||
<UInput v-model="local.vacation_reason" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Start (Tag)">
|
|
||||||
<UInput v-model="local.started_at" type="date" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Ende (Tag)">
|
|
||||||
<UInput v-model="local.stopped_at" type="date" />
|
|
||||||
</UFormGroup>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- SICK -->
|
|
||||||
<template v-else-if="local.type === 'sick'">
|
|
||||||
<UFormGroup label="Krankheitsgrund">
|
|
||||||
<UInput v-model="local.sick_reason" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Start (Tag)">
|
|
||||||
<UInput v-model="local.started_at" type="date" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Ende (Tag)">
|
|
||||||
<UInput v-model="local.stopped_at" type="date" />
|
|
||||||
</UFormGroup>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- WORK / OTHER -->
|
|
||||||
<template v-else>
|
|
||||||
<UFormGroup label="Beschreibung">
|
|
||||||
<UInput v-model="local.description" placeholder="Was wurde gemacht?" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Startzeit">
|
|
||||||
<UInput v-model="local.started_at" type="datetime-local" />
|
|
||||||
</UFormGroup>
|
|
||||||
|
|
||||||
<UFormGroup label="Endzeit">
|
|
||||||
<UInput v-model="local.stopped_at" type="datetime-local" />
|
|
||||||
</UFormGroup>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ACTIONS -->
|
|
||||||
<div class="flex justify-end gap-2 mt-4">
|
|
||||||
<UButton color="gray" label="Abbrechen" @click="show = false" />
|
|
||||||
<UButton color="primary" :loading="loading" type="submit" label="Speichern" />
|
|
||||||
</div>
|
</div>
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
</UModal>
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -5,156 +5,115 @@ export const useStaffTime = () => {
|
|||||||
const { $api, $dayjs } = useNuxtApp()
|
const { $api, $dayjs } = useNuxtApp()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
/**
|
// ... (list Funktion bleibt gleich) ...
|
||||||
* Lädt die Zeitspannen (Spans) für die Liste.
|
|
||||||
* Nutzt jetzt GET /api/staff/time/spans
|
|
||||||
*/
|
|
||||||
const list = async (filter?: { user_id?: string, from?: string, to?: string }) => {
|
const list = async (filter?: { user_id?: string, from?: string, to?: string }) => {
|
||||||
// Standard: Aktueller Monat
|
// ... (Code wie zuvor)
|
||||||
const from = filter?.from || $dayjs().startOf('month').format('YYYY-MM-DD')
|
const from = filter?.from || $dayjs().startOf('month').format('YYYY-MM-DD')
|
||||||
const to = filter?.to || $dayjs().endOf('month').format('YYYY-MM-DD')
|
const to = filter?.to || $dayjs().endOf('month').format('YYYY-MM-DD')
|
||||||
|
|
||||||
// Ziel-User: Entweder aus Filter (Admin-Ansicht) oder eigener User
|
|
||||||
const targetUserId = filter?.user_id || auth.user.id
|
const targetUserId = filter?.user_id || auth.user.id
|
||||||
|
const params = new URLSearchParams({ from, to, targetUserId })
|
||||||
const params = new URLSearchParams({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
// Der Endpoint erwartet targetUserId, wenn man Daten eines anderen Users will
|
|
||||||
targetUserId
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 💡 UPDATE: Prefix /api hinzugefügt
|
|
||||||
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
|
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
|
||||||
|
|
||||||
return (spans || []).map((span: any) => {
|
return (spans || []).map((span: any) => {
|
||||||
const start = $dayjs(span.startedAt)
|
const start = $dayjs(span.startedAt)
|
||||||
// Wenn endedAt null ist, läuft die Zeit noch -> Dauer bis "jetzt" berechnen für Anzeige
|
|
||||||
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
|
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
|
||||||
const duration = end.diff(start, 'minute')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// ID: Wir nehmen die erste Event-ID (Start-Event) als Referenz für Aktionen
|
|
||||||
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
|
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
|
||||||
|
eventIds: span.sourceEventIds || [],
|
||||||
// Mapping Backend-Status -> Frontend-State
|
|
||||||
state: span.status,
|
state: span.status,
|
||||||
|
|
||||||
// Zeitstempel
|
|
||||||
started_at: span.startedAt,
|
started_at: span.startedAt,
|
||||||
stopped_at: span.endedAt,
|
stopped_at: span.endedAt,
|
||||||
|
duration_minutes: end.diff(start, 'minute'),
|
||||||
duration_minutes: duration,
|
|
||||||
|
|
||||||
// Da der Endpoint nur die Spans zurückgibt, setzen wir die UserID
|
|
||||||
// auf den User, den wir angefragt haben.
|
|
||||||
user_id: targetUserId,
|
user_id: targetUserId,
|
||||||
|
|
||||||
type: span.type,
|
type: span.type,
|
||||||
|
|
||||||
// Payload/Description falls vorhanden
|
|
||||||
description: span.payload?.description || ''
|
description: span.payload?.description || ''
|
||||||
}
|
}
|
||||||
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) // Sortierung: Neueste oben
|
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Laden der Zeiten:", error)
|
console.error("Fehler beim Laden:", error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet einen neuen Zeiteintrag.
|
* Startet "jetzt" (Live-Modus).
|
||||||
* POST /api/staff/time/event
|
* Kann optional eine Zeit empfangen (für manuelle Korrekturen),
|
||||||
|
* aber wir nutzen dafür besser die createEntry Funktion unten.
|
||||||
*/
|
*/
|
||||||
const start = async (description = "Arbeitszeit") => {
|
const start = async (description = "Arbeitszeit", time?: string) => {
|
||||||
try {
|
|
||||||
// 💡 UPDATE: Prefix /api hinzugefügt
|
|
||||||
await $api('/api/staff/time/event', {
|
await $api('/api/staff/time/event', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
eventtype: 'work_start',
|
eventtype: 'work_start',
|
||||||
eventtime: new Date().toISOString(),
|
eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren
|
||||||
payload: { description }
|
payload: { description }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
console.error("Fehler beim Starten:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const stop = async () => {
|
||||||
* Stoppt die aktuelle Zeit.
|
await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString() } })
|
||||||
* POST /api/staff/time/event
|
}
|
||||||
*/
|
|
||||||
const stop = async (entryId?: string) => {
|
const submit = async (entry: any) => {
|
||||||
try {
|
const ids = entry.eventIds || (entry.id ? [entry.id] : [entry]);
|
||||||
// 💡 UPDATE: Prefix /api hinzugefügt
|
if (!ids || ids.length === 0) return
|
||||||
|
await $api('/api/staff/time/submit', { method: 'POST', body: { eventIds: ids } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const approve = async (entry: any) => {
|
||||||
|
if (!entry?.user_id) return
|
||||||
|
const ids = entry.eventIds || [entry.id];
|
||||||
|
await $api('/api/staff/time/approve', { method: 'POST', body: { eventIds: ids, employeeUserId: entry.user_id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reject = async (entry: any, reason = "Abgelehnt") => {
|
||||||
|
if (!entry?.user_id) return
|
||||||
|
const ids = entry.eventIds || [entry.id];
|
||||||
|
await $api('/api/staff/time/reject', { method: 'POST', body: { eventIds: ids, employeeUserId: entry.user_id, reason } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async (entry: any, newData: { start: string, end: string | null, type: string, description: string }) => {
|
||||||
|
if (!entry || !entry.eventIds || entry.eventIds.length === 0) {
|
||||||
|
throw new Error("Bearbeiten fehlgeschlagen: Keine IDs.")
|
||||||
|
}
|
||||||
|
await $api('/api/staff/time/edit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
originalEventIds: entry.eventIds,
|
||||||
|
newStart: newData.start,
|
||||||
|
newEnd: newData.end,
|
||||||
|
newType: newData.type,
|
||||||
|
description: newData.description,
|
||||||
|
reason: "Manuelle Bearbeitung"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 NEU: Manuellen Eintrag erstellen (Vergangenheit oder Zeitraum)
|
||||||
|
const createEntry = async (data: { start: string, end: string | null, type: string, description: string }) => {
|
||||||
|
// 1. Start Event senden
|
||||||
|
// Wir nutzen den dynamischen Typ (work_start, vacation_start etc.)
|
||||||
await $api('/api/staff/time/event', {
|
await $api('/api/staff/time/event', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
eventtype: 'work_end',
|
eventtype: `${data.type}_start`,
|
||||||
eventtime: new Date().toISOString()
|
eventtime: data.start,
|
||||||
|
payload: { description: data.description }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
console.error("Fehler beim Stoppen:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 2. End Event senden (falls vorhanden)
|
||||||
* Reicht einen Eintrag ein.
|
if (data.end) {
|
||||||
* POST /api/staff/time/submit
|
await $api('/api/staff/time/event', {
|
||||||
*/
|
|
||||||
const submit = async (entryId: string) => {
|
|
||||||
if (!entryId) return
|
|
||||||
try {
|
|
||||||
// 💡 UPDATE: Prefix /api hinzugefügt
|
|
||||||
await $api('/api/staff/time/submit', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
eventIds: [entryId]
|
eventtype: `${data.type}_end`,
|
||||||
|
eventtime: data.end
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
console.error("Fehler beim Einreichen:", error)
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return { list, start, stop, submit, approve, reject, update, createEntry }
|
||||||
* Genehmigt einen Eintrag.
|
|
||||||
* POST /api/staff/time/approve
|
|
||||||
*/
|
|
||||||
const approve = async (entry: any) => {
|
|
||||||
if (!entry || !entry.id || !entry.user_id) {
|
|
||||||
console.error("Ungültiger Eintrag für Genehmigung (ID oder UserID fehlt)", entry)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 💡 UPDATE: Prefix /api hinzugefügt
|
|
||||||
await $api('/api/staff/time/approve', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
eventIds: [entry.id],
|
|
||||||
employeeUserId: entry.user_id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Fehler beim Genehmigen:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
list,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
submit,
|
|
||||||
approve
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@fullcalendar/timegrid": "^6.1.10",
|
"@fullcalendar/timegrid": "^6.1.10",
|
||||||
"@fullcalendar/vue3": "^6.1.10",
|
"@fullcalendar/vue3": "^6.1.10",
|
||||||
"@iconify/json": "^2.2.171",
|
"@iconify/json": "^2.2.171",
|
||||||
|
"@mmote/niimbluelib": "^0.0.1-alpha.29",
|
||||||
"@nuxt/ui-pro": "^1.6.0",
|
"@nuxt/ui-pro": "^1.6.0",
|
||||||
"@nuxtjs/fontaine": "^0.4.1",
|
"@nuxtjs/fontaine": "^0.4.1",
|
||||||
"@nuxtjs/google-fonts": "^3.1.0",
|
"@nuxtjs/google-fonts": "^3.1.0",
|
||||||
@@ -63,7 +64,9 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"fast-sort": "^3.4.1",
|
"fast-sort": "^3.4.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
"image-js": "^1.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"license-checker": "^25.0.1",
|
||||||
"maplibre-gl": "^4.7.0",
|
"maplibre-gl": "^4.7.0",
|
||||||
"nuxt-editorjs": "^1.0.4",
|
"nuxt-editorjs": "^1.0.4",
|
||||||
"nuxt-viewport": "^2.0.6",
|
"nuxt-viewport": "^2.0.6",
|
||||||
@@ -80,6 +83,8 @@
|
|||||||
"vue-chartjs": "^5.3.1",
|
"vue-chartjs": "^5.3.1",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"vuetify": "^3.4.0-beta.1",
|
"vuetify": "^3.4.0-beta.1",
|
||||||
"zebra-browser-print-wrapper": "^0.1.4"
|
"zebra-browser-print-wrapper": "^0.1.4",
|
||||||
|
"zod": "^3.25.76",
|
||||||
|
"zpl-renderer-js": "^2.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,35 +7,49 @@ definePageMeta({
|
|||||||
layout: "default",
|
layout: "default",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { list, start, stop, submit, approve } = useStaffTime()
|
const { list, start, stop, submit, approve, reject } = useStaffTime()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { $dayjs } = useNuxtApp()
|
||||||
|
|
||||||
// MOBILE DETECTION
|
// MOBILE DETECTION
|
||||||
const platformIsNative = useCapacitor().getIsNative()
|
const platformIsNative = useCapacitor().getIsNative()
|
||||||
|
|
||||||
// STATE
|
// STATE
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const view = ref('list') // 'list' | 'timeline'
|
||||||
const editEntry = ref(null)
|
|
||||||
|
|
||||||
// 👥 Nutzer-Filter
|
// MODAL STATES
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const entryToEdit = ref(null)
|
||||||
|
|
||||||
|
const showRejectModal = ref(false)
|
||||||
|
const entryToReject = ref(null)
|
||||||
|
const rejectReason = ref("")
|
||||||
|
|
||||||
|
// FILTER & USER
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
// WICHTIG: Standardmäßig IMMER den aktuellen User wählen (kein null mehr)
|
|
||||||
const selectedUser = ref(auth.user.id)
|
const selectedUser = ref(auth.user.id)
|
||||||
const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all'))
|
const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all'))
|
||||||
|
|
||||||
// LIST + ACTIVE
|
// DATA
|
||||||
const entries = ref([])
|
const entries = ref([])
|
||||||
|
|
||||||
// Active berechnet sich jetzt aus den geladenen Einträgen.
|
|
||||||
// Da wir immer nur EINEN User laden, ist ein laufender Eintrag hier
|
|
||||||
// automatisch der aktive Eintrag des angezeigten Users.
|
|
||||||
const active = computed(() => entries.value.find(e => !e.stopped_at))
|
const active = computed(() => entries.value.find(e => !e.stopped_at))
|
||||||
|
|
||||||
// Prüft, ob wir gerade das eigene Profil ansehen (für Start/Stop Buttons)
|
|
||||||
const isViewingSelf = computed(() => selectedUser.value === auth.user.id)
|
const isViewingSelf = computed(() => selectedUser.value === auth.user.id)
|
||||||
|
|
||||||
|
// GROUPING
|
||||||
|
const groupedEntries = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
entries.value.forEach(entry => {
|
||||||
|
const dateKey = $dayjs(entry.started_at).format('YYYY-MM-DD')
|
||||||
|
if (!groups[dateKey]) groups[dateKey] = []
|
||||||
|
groups[dateKey].push(entry)
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// CONFIG
|
||||||
const typeLabel = {
|
const typeLabel = {
|
||||||
work: "Arbeitszeit",
|
work: "Arbeitszeit",
|
||||||
vacation: "Urlaub",
|
vacation: "Urlaub",
|
||||||
@@ -52,26 +66,20 @@ const typeColor = {
|
|||||||
other: "gray"
|
other: "gray"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ACTIONS
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!canViewAll.value) return
|
if (!canViewAll.value) return
|
||||||
const res = (await useNuxtApp().$api("/api/tenant/profiles")).data
|
const res = (await useNuxtApp().$api("/api/tenant/profiles")).data
|
||||||
users.value = res
|
users.value = res
|
||||||
}
|
}
|
||||||
|
|
||||||
// LOAD ENTRIES (Erzwingt jetzt immer eine User-ID)
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!selectedUser.value) return
|
if (!selectedUser.value) return
|
||||||
|
entries.value = await list({ user_id: selectedUser.value })
|
||||||
entries.value = await list({
|
|
||||||
user_id: selectedUser.value // 👈 Hier wird der Filter erzwungen
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
// Sicherheitshalber: Starten nur erlauben, wenn man sich selbst ansieht
|
|
||||||
if (!isViewingSelf.value) return
|
if (!isViewingSelf.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await start("Arbeitszeit gestartet")
|
await start("Arbeitszeit gestartet")
|
||||||
await load()
|
await load()
|
||||||
@@ -80,9 +88,6 @@ async function handleStart() {
|
|||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
if (!active.value) return
|
if (!active.value) return
|
||||||
// Stoppen darf man ggf. auch andere (als Admin), daher hier kein isViewingSelf Check
|
|
||||||
// oder je nach Logik anpassen. Hier erlauben wir das Stoppen des angezeigten Timers.
|
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await stop(active.value.id)
|
await stop(active.value.id)
|
||||||
await load()
|
await load()
|
||||||
@@ -90,28 +95,53 @@ async function handleStop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(entry) {
|
function handleEdit(entry) {
|
||||||
editEntry.value = entry
|
entryToEdit.value = entry
|
||||||
showModal.value = true
|
showEditModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(entry) {
|
async function handleSubmit(entry) {
|
||||||
|
loading.value = true
|
||||||
await submit(entry)
|
await submit(entry)
|
||||||
await load()
|
await load()
|
||||||
|
loading.value = false
|
||||||
|
toast.add({ title: 'Zeit eingereicht', color: 'green' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApprove(entry) {
|
async function handleApprove(entry) {
|
||||||
|
loading.value = true
|
||||||
await approve(entry)
|
await approve(entry)
|
||||||
await load()
|
await load()
|
||||||
|
loading.value = false
|
||||||
|
toast.add({ title: 'Zeit genehmigt', color: 'green' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watcher: Wenn der User im Dropdown gewechselt wird, neu laden
|
function openRejectModal(entry) {
|
||||||
watch(selectedUser, () => {
|
entryToReject.value = entry
|
||||||
load()
|
rejectReason.value = ""
|
||||||
})
|
showRejectModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReject() {
|
||||||
|
if (!entryToReject.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await reject(entryToReject.value, rejectReason.value || "Vom Administrator abgelehnt")
|
||||||
|
toast.add({ title: 'Zeit abgelehnt', color: 'green' })
|
||||||
|
showRejectModal.value = false
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ title: 'Fehler beim Ablehnen', description: e.message, color: 'red' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
entryToReject.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedUser, () => { load() })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
await load() // Lädt initial den eingeloggten User
|
await load()
|
||||||
setPageLayout(platformIsNative ? 'mobile' : 'default')
|
setPageLayout(platformIsNative ? 'mobile' : 'default')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -120,64 +150,20 @@ onMounted(async () => {
|
|||||||
<template v-if="!platformIsNative">
|
<template v-if="!platformIsNative">
|
||||||
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
|
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
|
||||||
|
|
||||||
<!--<UDashboardToolbar>
|
|
||||||
<template #left>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-heroicons-clock" class="text-primary-500" />
|
|
||||||
<span v-if="active" class="text-primary-600 font-medium">
|
|
||||||
<span v-if="isViewingSelf">Läuft seit</span>
|
|
||||||
<span v-else>Mitarbeiter aktiv seit</span>
|
|
||||||
{{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-gray-500">
|
|
||||||
<span v-if="isViewingSelf">Keine aktive Zeit</span>
|
|
||||||
<span v-else>Mitarbeiter nicht aktiv</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<template v-if="isViewingSelf">
|
|
||||||
<UButton
|
|
||||||
v-if="active"
|
|
||||||
color="red"
|
|
||||||
icon="i-heroicons-stop"
|
|
||||||
:loading="loading"
|
|
||||||
label="Stoppen"
|
|
||||||
@click="handleStop"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-else
|
|
||||||
color="green"
|
|
||||||
icon="i-heroicons-play"
|
|
||||||
:loading="loading"
|
|
||||||
label="Starten"
|
|
||||||
@click="handleStart"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="active && canViewAll">
|
|
||||||
<UButton
|
|
||||||
color="red"
|
|
||||||
variant="soft"
|
|
||||||
icon="i-heroicons-stop"
|
|
||||||
:loading="loading"
|
|
||||||
label="Mitarbeiter stoppen"
|
|
||||||
@click="handleStop"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
color="primary"
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
label="Zeit"
|
|
||||||
@click="() => { editEntry = null; showModal = true }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UDashboardToolbar>-->
|
|
||||||
|
|
||||||
<UDashboardToolbar>
|
<UDashboardToolbar>
|
||||||
<template #left>
|
<template #left>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2 border-r pr-4 mr-2">
|
||||||
|
<UIcon name="i-heroicons-clock" class="w-5 h-5" :class="active ? 'text-primary-500 animate-pulse' : 'text-gray-400'" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 uppercase font-bold">Status</span>
|
||||||
|
<span v-if="active" class="text-sm font-medium text-primary-600">
|
||||||
|
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-gray-600">Nicht aktiv</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="canViewAll" class="flex items-center gap-2">
|
<div v-if="canViewAll" class="flex items-center gap-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="selectedUser"
|
v-model="selectedUser"
|
||||||
@@ -188,234 +174,300 @@ onMounted(async () => {
|
|||||||
class="min-w-[220px]"
|
class="min-w-[220px]"
|
||||||
:clearable="false"
|
:clearable="false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UTooltip text="Anwesenheiten auswerten">
|
<UTooltip text="Anwesenheiten auswerten">
|
||||||
<UButton
|
<UButton
|
||||||
:disabled="!selectedUser"
|
:disabled="!selectedUser"
|
||||||
color="gray"
|
color="gray"
|
||||||
icon="i-heroicons-chart-bar"
|
icon="i-heroicons-chart-bar"
|
||||||
label="Auswertung"
|
variant="ghost"
|
||||||
variant="soft"
|
|
||||||
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
|
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<div class="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
|
||||||
|
<UTooltip text="Listenansicht">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:color="view === 'list' ? 'white' : 'gray'"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-table-cells"
|
||||||
|
@click="view = 'list'"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Zeitstrahl">
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
:color="view === 'timeline' ? 'white' : 'gray'"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-list-bullet"
|
||||||
|
@click="view = 'timeline'"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="isViewingSelf">
|
||||||
|
<UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" label="Stoppen" @click="handleStop" />
|
||||||
|
<UButton v-else color="green" icon="i-heroicons-play" :loading="loading" label="Starten" @click="handleStart" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="active && canViewAll">
|
||||||
|
<UButton color="red" variant="soft" icon="i-heroicons-stop" :loading="loading" label="Mitarbeiter stoppen" @click="handleStop" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UButton color="gray" variant="solid" icon="i-heroicons-plus" label="Erfassen" @click="() => { entryToEdit = null; showEditModal = true }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardToolbar>
|
</UDashboardToolbar>
|
||||||
|
|
||||||
|
<UDashboardPanelContent class="p-0 sm:p-4">
|
||||||
|
|
||||||
<UDashboardPanelContent>
|
<UCard v-if="view === 'list'" :ui="{ body: { padding: 'p-0 sm:p-0' } }">
|
||||||
<UTable
|
<UTable
|
||||||
:rows="entries"
|
:rows="entries"
|
||||||
:columns="[
|
:columns="[
|
||||||
{ key: 'actions', label: '' },
|
{ key: 'actions', label: 'Aktionen', class: 'w-32' },
|
||||||
{ key: 'state', label: 'Status' },
|
{ key: 'state', label: 'Status' },
|
||||||
{ key: 'started_at', label: 'Start' },
|
{ key: 'started_at', label: 'Start' },
|
||||||
{ key: 'stopped_at', label: 'Ende' },
|
{ key: 'stopped_at', label: 'Ende' },
|
||||||
{ key: 'duration_minutes', label: 'Dauer' },
|
{ key: 'duration_minutes', label: 'Dauer' },
|
||||||
// { key: 'user', label: 'Mitarbeiter' }, // Spalte entfernt, da wir eh nur einen User sehen
|
|
||||||
{ key: 'type', label: 'Typ' },
|
{ key: 'type', label: 'Typ' },
|
||||||
{ key: 'description', label: 'Beschreibung' },
|
{ key: 'description', label: 'Beschreibung' },
|
||||||
]"
|
]"
|
||||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Zeiten anzuzeigen' }"
|
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Zeiten anzuzeigen' }"
|
||||||
>
|
>
|
||||||
<template #state-data="{ row }">
|
<template #state-data="{ row }">
|
||||||
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
|
<UBadge v-if="row.state === 'approved'" color="green" variant="subtle">Genehmigt</UBadge>
|
||||||
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span>
|
<UBadge v-else-if="row.state === 'submitted'" color="cyan" variant="subtle">Eingereicht</UBadge>
|
||||||
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span>
|
<UBadge v-else-if="row.state === 'rejected'" color="red" variant="subtle">Abgelehnt</UBadge>
|
||||||
|
<UBadge v-else color="gray" variant="subtle">Entwurf</UBadge>
|
||||||
</template>
|
</template>
|
||||||
<template #type-data="{ row }">
|
<template #type-data="{ row }">
|
||||||
<UBadge :color="typeColor[row.type] || 'gray'">
|
<UBadge :color="typeColor[row.type] || 'gray'" variant="soft">{{ typeLabel[row.type] || row.type }}</UBadge>
|
||||||
{{ typeLabel[row.type] || row.type }}
|
|
||||||
</UBadge>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #started_at-data="{ row }">
|
<template #started_at-data="{ row }">
|
||||||
<span v-if="row.type === 'vacation' || row.type === 'sick'">
|
<span v-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}</span>
|
||||||
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}
|
<span v-else>{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}</span>
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #stopped_at-data="{ row }">
|
<template #stopped_at-data="{ row }">
|
||||||
<span v-if="!row.stopped_at" class="text-primary-500 font-medium">
|
<span v-if="!row.stopped_at" class="text-primary-500 font-medium animate-pulse">läuft...</span>
|
||||||
läuft...
|
<span v-else-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}</span>
|
||||||
</span>
|
<span v-else>{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}</span>
|
||||||
<span v-else-if="row.type === 'vacation' || row.type === 'sick'">
|
|
||||||
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #duration_minutes-data="{ row }">
|
<template #duration_minutes-data="{ row }">
|
||||||
<span v-if="row.type === 'vacation' || row.type === 'sick'">
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
|
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions-data="{ row }">
|
<template #actions-data="{ row }">
|
||||||
<UTooltip text="Zeit genehmigen" v-if="row.state === 'submitted' && canViewAll">
|
<div class="flex items-center gap-1">
|
||||||
<UButton
|
<UTooltip text="Einreichen" v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at">
|
||||||
variant="ghost"
|
<UButton size="xs" color="cyan" variant="ghost" icon="i-heroicons-paper-airplane" @click="handleSubmit(row)" :loading="loading" />
|
||||||
icon="i-heroicons-check-circle"
|
|
||||||
@click="handleApprove(row)"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UTooltip text="Zeit einreichen" v-if="row.state === 'draft'">
|
<UTooltip text="Genehmigen" v-if="row.state === 'submitted' && canViewAll">
|
||||||
<UButton
|
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row)" :loading="loading" />
|
||||||
variant="ghost"
|
|
||||||
icon="i-heroicons-arrow-right-end-on-rectangle"
|
|
||||||
@click="handleSubmit(row)"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UTooltip text="Zeit bearbeiten" v-if="row.state === 'draft'">
|
<UTooltip text="Ablehnen" v-if="(row.state === 'submitted' || row.state === 'approved') && canViewAll">
|
||||||
<UButton
|
<UButton size="xs" color="red" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row)" :loading="loading" />
|
||||||
variant="ghost"
|
|
||||||
icon="i-heroicons-pencil-square"
|
|
||||||
@click="handleEdit(row)"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UTooltip text="Bearbeiten" v-if="['draft', 'factual', 'submitted'].includes(row.state)">
|
||||||
|
<UButton size="xs" color="gray" variant="ghost" icon="i-heroicons-pencil-square" @click="handleEdit(row)" />
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #description-data="{ row }">
|
<template #description-data="{ row }">
|
||||||
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span>
|
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span>
|
||||||
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
|
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
|
||||||
<span v-else>{{row.description}}</span>
|
<span v-else>{{row.description}}</span>
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<div v-else class="max-w-5xl mx-auto pb-20">
|
||||||
|
|
||||||
|
<div v-for="(group, date) in groupedEntries" :key="date" class="relative group/date">
|
||||||
|
|
||||||
|
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900 py-4 mb-4 border-b border-gray-200 dark:border-gray-800 flex items-center gap-3">
|
||||||
|
<div class="w-2.5 h-2.5 rounded-full bg-gray-400"></div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 capitalize">
|
||||||
|
{{ useNuxtApp().$dayjs(date).format('dddd, DD. MMMM') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-gray-500 font-normal mt-0.5">
|
||||||
|
{{ group.length }} Einträge
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute left-[5px] top-14 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-last/date:bottom-auto group-last/date:h-full"></div>
|
||||||
|
|
||||||
|
<div class="space-y-6 pb-8">
|
||||||
|
<div v-for="entry in group" :key="entry.id" class="relative pl-8">
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-6 w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 shadow-sm z-0"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30': !entry.stopped_at,
|
||||||
|
'bg-gray-400': entry.stopped_at && entry.type === 'work',
|
||||||
|
'bg-yellow-400': entry.type === 'vacation',
|
||||||
|
'bg-red-400': entry.type === 'sick'
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-between items-center p-4 border-b border-gray-100 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="font-mono text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
{{ useNuxtApp().$dayjs(entry.started_at).format('HH:mm') }}
|
||||||
|
<span class="text-gray-400 text-sm">bis</span>
|
||||||
|
<span v-if="entry.stopped_at">{{ useNuxtApp().$dayjs(entry.stopped_at).format('HH:mm') }}</span>
|
||||||
|
<span v-else class="text-primary-500 animate-pulse text-sm uppercase font-bold tracking-wider">Läuft</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UBadge :color="typeColor[entry.type]" variant="soft" size="xs">{{ typeLabel[entry.type] }}</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="entry.duration_minutes" class="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{{ useFormatDuration(entry.duration_minutes) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<UBadge v-if="entry.state === 'approved'" color="green" size="xs" variant="solid">Genehmigt</UBadge>
|
||||||
|
<UBadge v-else-if="entry.state === 'submitted'" color="cyan" size="xs" variant="solid">Eingereicht</UBadge>
|
||||||
|
<UBadge v-else-if="entry.state === 'rejected'" color="red" size="xs" variant="solid">Abgelehnt</UBadge>
|
||||||
|
<UBadge v-else color="gray" size="xs" variant="subtle">Entwurf</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 text-sm whitespace-pre-wrap">
|
||||||
|
{{ entry.description || 'Keine Beschreibung angegeben.' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="entry.type === 'vacation'" class="text-sm text-gray-500 italic mt-1">
|
||||||
|
Grund: {{ entry.vacation_reason }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/30 px-4 py-2 flex justify-end gap-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<UButton
|
||||||
|
v-if="(entry.state === 'draft' || entry.state === 'factual') && entry.stopped_at"
|
||||||
|
size="xs" color="cyan" variant="solid" icon="i-heroicons-paper-airplane" label="Einreichen"
|
||||||
|
@click="handleSubmit(entry)" :loading="loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="entry.state === 'submitted' && canViewAll"
|
||||||
|
size="xs" color="green" variant="solid" icon="i-heroicons-check" label="Genehmigen"
|
||||||
|
@click="handleApprove(entry)" :loading="loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="(entry.state === 'submitted' || entry.state === 'approved') && canViewAll"
|
||||||
|
size="xs" color="red" variant="ghost" icon="i-heroicons-x-mark" label="Ablehnen"
|
||||||
|
@click="openRejectModal(entry)" :loading="loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="['draft', 'factual', 'submitted'].includes(entry.state)"
|
||||||
|
size="xs" color="gray" variant="ghost" icon="i-heroicons-pencil-square" label="Bearbeiten"
|
||||||
|
@click="handleEdit(entry)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="entries.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<UIcon name="i-heroicons-calendar" class="w-12 h-12 mb-2 opacity-50" />
|
||||||
|
<p>Keine Einträge im gewählten Zeitraum.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<UDashboardNavbar title="Zeiterfassung" />
|
<UDashboardNavbar title="Zeiterfassung" />
|
||||||
|
|
||||||
<div class="relative flex flex-col h-[100dvh] overflow-hidden">
|
<div class="relative flex flex-col h-[100dvh] overflow-hidden">
|
||||||
|
|
||||||
<div v-if="isViewingSelf" class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
|
<div v-if="isViewingSelf" class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
|
||||||
<UCard class="p-3">
|
<UCard class="p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500 text-sm">Aktive Zeit</p>
|
<p class="text-gray-500 text-sm">Aktive Zeit</p>
|
||||||
<p v-if="active" class="text-primary-600 font-semibold">
|
<p v-if="active" class="text-primary-600 font-semibold animate-pulse">
|
||||||
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
|
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-gray-600">Keine aktive Zeit</p>
|
<p v-else class="text-gray-600">Keine aktive Zeit</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
<UButton
|
<template v-if="isViewingSelf">
|
||||||
v-if="active"
|
<UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" @click="handleStop" />
|
||||||
color="red"
|
<UButton v-else color="green" icon="i-heroicons-play" :loading="loading" @click="handleStart" />
|
||||||
icon="i-heroicons-stop"
|
</template>
|
||||||
:loading="loading"
|
</div>
|
||||||
@click="handleStop"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-else
|
|
||||||
color="green"
|
|
||||||
icon="i-heroicons-play"
|
|
||||||
:loading="loading"
|
|
||||||
@click="handleStart"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-3 mt-3">
|
<div class="px-3 mt-3">
|
||||||
<UButton
|
<UButton color="gray" icon="i-heroicons-chart-bar" label="Auswertung" class="w-full" variant="soft" @click="router.push(`/staff/time/${selectedUser}/evaluate`)" />
|
||||||
color="gray"
|
|
||||||
icon="i-heroicons-chart-bar"
|
|
||||||
label="Auswertung"
|
|
||||||
class="w-full"
|
|
||||||
variant="soft"
|
|
||||||
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UDashboardPanelContent class="flex-1 overflow-y-auto p-3 space-y-4 pb-24">
|
<UDashboardPanelContent class="flex-1 overflow-y-auto p-3 space-y-4 pb-24">
|
||||||
|
<UCard v-for="row in entries" :key="row.id" class="p-4 border rounded-xl" @click="handleEdit(row)">
|
||||||
<UCard
|
|
||||||
v-for="row in entries"
|
|
||||||
:key="row.id"
|
|
||||||
class="p-4 border rounded-xl active:scale-[0.98] transition cursor-pointer"
|
|
||||||
@click="handleEdit(row)"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="font-semibold flex items-center gap-2">
|
<div class="font-semibold flex items-center gap-2">
|
||||||
<span>{{ row.description || 'Keine Beschreibung' }}</span>
|
<span class="truncate max-w-[150px]">{{ row.description || 'Keine Beschreibung' }}</span>
|
||||||
|
<UBadge :color="typeColor[row.type]" class="text-xs">{{ typeLabel[row.type] }}</UBadge>
|
||||||
<UBadge :color="typeColor[row.type]" class="text-xs">
|
|
||||||
{{ typeLabel[row.type] }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<UBadge v-if="row.state === 'approved'" color="green">Genehmigt</UBadge>
|
||||||
<UBadge
|
<UBadge v-else-if="row.state === 'submitted'" color="cyan">Eingereicht</UBadge>
|
||||||
:color="{ approved: 'primary', submitted: 'cyan', draft: 'red' }[row.state]"
|
<UBadge v-else-if="row.state === 'rejected'" color="red">Abgelehnt</UBadge>
|
||||||
>
|
<UBadge v-else color="gray">Entwurf</UBadge>
|
||||||
{{ { approved: 'Genehmigt', submitted: 'Eingereicht', draft: 'Entwurf' }[row.state] || row.state }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Start: {{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}</p>
|
||||||
<p class="text-sm text-gray-500 mt-1">
|
<p class="text-sm text-gray-500">Ende: <span v-if="row.stopped_at">{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}</span></p>
|
||||||
Start: {{ useNuxtApp().$dayjs(row.started_at).format('DD.MM.YY HH:mm') }}
|
<div class="flex gap-2 mt-3 justify-end">
|
||||||
</p>
|
<UButton v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at" color="cyan" size="sm" icon="i-heroicons-paper-airplane" label="Einreichen" variant="soft" @click.stop="handleSubmit(row)" :loading="loading" />
|
||||||
|
<UButton v-if="row.state === 'submitted' && canViewAll" color="green" size="sm" icon="i-heroicons-check" label="Genehmigen" variant="soft" @click.stop="handleApprove(row)" :loading="loading" />
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Ende:
|
|
||||||
<span v-if="row.stopped_at">
|
|
||||||
{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-primary-500 font-medium">läuft...</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Dauer:
|
|
||||||
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : '-' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-3">
|
|
||||||
<UButton
|
|
||||||
v-if="row.state === 'draft'"
|
|
||||||
color="gray"
|
|
||||||
icon="i-heroicons-arrow-right-end-on-rectangle"
|
|
||||||
label="Einreichen"
|
|
||||||
variant="soft"
|
|
||||||
@click.stop="handleSubmit(row)"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-if="row.state === 'submitted' && canViewAll"
|
|
||||||
color="primary"
|
|
||||||
icon="i-heroicons-check"
|
|
||||||
label="Genehmigen"
|
|
||||||
variant="soft"
|
|
||||||
@click.stop="handleApprove(row)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
<FloatingActionButton icon="i-heroicons-plus" class="!fixed bottom-6 right-6 z-50" color="primary" @click="() => { entryToEdit = null; showEditModal = true }" />
|
||||||
<FloatingActionButton
|
|
||||||
icon="i-heroicons-plus"
|
|
||||||
class="!fixed bottom-6 right-6 z-50"
|
|
||||||
color="primary"
|
|
||||||
@click="() => { editEntry = null; showModal = true }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<StaffTimeEntryModal
|
<StaffTimeEntryModal
|
||||||
v-model="showModal"
|
v-model="showEditModal"
|
||||||
:entry="editEntry"
|
:entry="entryToEdit"
|
||||||
@saved="load"
|
@saved="load"
|
||||||
:users="users"
|
|
||||||
:can-select-user="canViewAll"
|
|
||||||
:default-user-id="selectedUser"
|
:default-user-id="selectedUser"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UModal v-model="showRejectModal">
|
||||||
|
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||||
|
Zeiteintrag ablehnen
|
||||||
|
</h3>
|
||||||
|
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="showRejectModal = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Der Eintrag wird als "Rejected" markiert und nicht mehr zur Arbeitszeit gezählt.
|
||||||
|
</p>
|
||||||
|
<UFormGroup label="Grund (optional)" name="reason">
|
||||||
|
<UTextarea v-model="rejectReason" placeholder="Falsche Buchung, Doppelt, etc." autofocus />
|
||||||
|
</UFormGroup>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton color="gray" variant="soft" @click="showRejectModal = false">Abbrechen</UButton>
|
||||||
|
<UButton color="red" :loading="loading" @click="confirmReject">Bestätigen</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
Reference in New Issue
Block a user