Merge branch 'devCorrected' into 'beta'

Fixed Times

See merge request fedeo/software!50
This commit is contained in:
2025-12-14 16:20:33 +00:00
4 changed files with 386 additions and 253 deletions

View File

@@ -7,7 +7,20 @@ export const useFunctions = () => {
const supabase = useSupabaseClient() const supabase = useSupabaseClient()
const getWorkingTimesEvaluationData = async (user_id, startDate, endDate) => { const getWorkingTimesEvaluationData = async (user_id, startDate, endDate) => {
return (await useNuxtApp().$api(`/api/functions/timeevaluation/${user_id}?start_date=${startDate}&end_date=${endDate}`)) // Der neue Endpunkt ist /staff/time/evaluation und erwartet die Benutzer-ID als targetUserId Query-Parameter.
// Wir bauen den Query-String zusammen.
const queryParams = new URLSearchParams({
from: startDate,
to: endDate,
targetUserId: user_id, // Die ID wird als targetUserId übergeben
});
// Der neue API-Pfad verwendet nur noch den Basis-Endpunkt.
const url = `/api/staff/time/evaluation?${queryParams.toString()}`;
// Annahme: useNuxtApp().$api führt den GET-Request aus und liefert die Daten zurück.
return (await useNuxtApp().$api(url));
} }
const useNextNumber = async (numberRange) => { const useNextNumber = async (numberRange) => {

View File

@@ -1,77 +1,160 @@
interface StaffTimeEntry { import { defineStore } from 'pinia'
id: string import { useAuthStore } from '~/stores/auth'
started_at: string
stopped_at?: string | null
duration_minutes?: number | null
type: string
description?: string | null
created_at?: string
}
export function useStaffTime() { export const useStaffTime = () => {
const { $api } = useNuxtApp() const { $api, $dayjs } = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
/**
* 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 }) => {
// Standard: Aktueller Monat
const from = filter?.from || $dayjs().startOf('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
async function list(params?: { user_id?: string }) { const params = new URLSearchParams({
const query = new URLSearchParams() from,
if (params?.user_id) query.append("user_id", params.user_id) to,
// Der Endpoint erwartet targetUserId, wenn man Daten eines anderen Users will
return await $api(`/api/staff/time${query.toString() ? `?${query}` : ''}`, { method: 'GET' }) targetUserId
}
async function start(description?: string) {
return await $api<StaffTimeEntry>('/api/staff/time', {
method: 'POST',
body: {
started_at: new Date().toISOString(),
type: 'work',
description,
},
}) })
try {
// 💡 UPDATE: Prefix /api hinzugefügt
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
return (spans || []).map((span: any) => {
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 duration = end.diff(start, 'minute')
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,
// Mapping Backend-Status -> Frontend-State
state: span.status,
// Zeitstempel
started_at: span.startedAt,
stopped_at: span.endedAt,
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,
type: span.type,
// Payload/Description falls vorhanden
description: span.payload?.description || ''
}
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) // Sortierung: Neueste oben
} catch (error) {
console.error("Fehler beim Laden der Zeiten:", error)
return []
}
} }
async function stop(id: string) { /**
return await $api<StaffTimeEntry>(`/api/staff/time/${id}/stop`, { * Startet einen neuen Zeiteintrag.
method: 'PUT', * POST /api/staff/time/event
body: { stopped_at: new Date().toISOString() }, */
}) const start = async (description = "Arbeitszeit") => {
try {
// 💡 UPDATE: Prefix /api hinzugefügt
await $api('/api/staff/time/event', {
method: 'POST',
body: {
eventtype: 'work_start',
eventtime: new Date().toISOString(),
payload: { description }
}
})
} catch (error) {
console.error("Fehler beim Starten:", error)
throw error
}
} }
async function submit(id: string) { /**
return await $api<StaffTimeEntry>(`/api/staff/time/${id}`, { * Stoppt die aktuelle Zeit.
method: 'PUT', * POST /api/staff/time/event
body: { state: 'submitted' }, */
}) const stop = async (entryId?: string) => {
try {
// 💡 UPDATE: Prefix /api hinzugefügt
await $api('/api/staff/time/event', {
method: 'POST',
body: {
eventtype: 'work_end',
eventtime: new Date().toISOString()
}
})
} catch (error) {
console.error("Fehler beim Stoppen:", error)
throw error
}
} }
async function approve(id: string) { /**
const auth = useAuthStore() * Reicht einen Eintrag ein.
const now = useNuxtApp().$dayjs().toISOString() * POST /api/staff/time/submit
*/
return await $api(`/api/staff/time/${id}`, { const submit = async (entryId: string) => {
method: 'PUT', if (!entryId) return
body: { try {
state: 'approved', // 💡 UPDATE: Prefix /api hinzugefügt
//@ts-ignore await $api('/api/staff/time/submit', {
approved_by: auth.user.id, method: 'POST',
approved_at: now, body: {
}, eventIds: [entryId]
}) }
})
} catch (error) {
console.error("Fehler beim Einreichen:", error)
throw error
}
} }
async function get(id: string) { /**
return await $api<StaffTimeEntry>(`/api/staff/time/${id}`, { method: 'GET' }) * 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
}
} }
async function create(data: Record<string, any>) { return {
return await $api('/api/staff/time', { method: 'POST', body: data }) list,
start,
stop,
submit,
approve
} }
async function update(id: string, data: Record<string, any>) {
return await $api(`/api/staff/time/${id}`, { method: 'PUT', body: data })
}
return { list, start, stop,submit,approve, get, create, update }
} }

View File

@@ -4,10 +4,25 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
// 🔹 State // 🔹 State
const workingtimes = ref([]) const workingTimeInfo = ref<{
const absencerequests = ref([]) userId: string;
const workingTimeInfo = ref(null) spans: any[]; // Neue Struktur für die Detailansicht/Tabelle
summary: {
sumWorkingMinutesSubmitted: number;
sumWorkingMinutesApproved: number;
sumWorkingMinutesRecreationDays: number;
sumRecreationDays: number;
sumWorkingMinutesVacationDays: number;
sumVacationDays: number;
sumWorkingMinutesSickDays: number;
sumSickDays: number;
timeSpanWorkingMinutes: number;
saldoApproved: number;
saldoSubmitted: number;
} | null; // Neue Struktur für die Zusammenfassung
} | null>(null)
const platformIsNative = ref(useCapacitor().getIsNative()) const platformIsNative = ref(useCapacitor().getIsNative())
@@ -20,12 +35,38 @@ const showDocument = ref(false)
const uri = ref("") const uri = ref("")
const itemInfo = ref({}) const itemInfo = ref({})
const profile = ref(null)
// 💡 Die ID des Benutzers, dessen Daten wir abrufen (aus der Route)
const evaluatedUserId = computed(() => route.params.id as string)
/**
* Konvertiert Minuten in das Format HH:MM h
*/
function formatMinutesToHHMM(minutes = 0) { function formatMinutesToHHMM(minutes = 0) {
const h = Math.floor(minutes / 60) const h = Math.floor(minutes / 60)
const m = Math.floor(minutes % 60) const m = Math.floor(minutes % 60)
return `${h}:${String(m).padStart(2, "0")} h` return `${h}:${String(m).padStart(2, "0")} h`
} }
/**
* Berechnet die Dauer zwischen startedAt und endedAt in Minuten.
*/
function calculateDurationMinutes(start: string, end: string): number {
const startTime = $dayjs(start);
const endTime = $dayjs(end);
return endTime.diff(startTime, 'minute');
}
/**
* Formatiert die Dauer (in Minuten) in HH:MM h
*/
function formatSpanDuration(start: string, end: string): string {
const minutes = calculateDurationMinutes(start, end);
return formatMinutesToHHMM(minutes);
}
// 📅 Zeitraumumschaltung // 📅 Zeitraumumschaltung
function changeRange() { function changeRange() {
const rangeMap = { const rangeMap = {
@@ -56,41 +97,67 @@ function changeRange() {
loadWorkingTimeInfo() loadWorkingTimeInfo()
} }
const profile = ref(null) // 📊 Daten laden (Initialisierung)
// 📊 Daten laden
async function setupPage() { async function setupPage() {
await changeRange() await changeRange()
profile.value = (await useNuxtApp().$api("/api/tenant/profiles")).data.find(i => i.user_id === route.params.id) // Lade das Profil des Benutzers, der ausgewertet wird (route.params.id)
try {
const response = await useNuxtApp().$api(`/api/tenant/profiles`);
// Findet das Profil des Benutzers, dessen ID in der Route steht
profile.value = response.data.find(i => i.user_id === evaluatedUserId.value);
} catch (error) {
console.error("Fehler beim Laden des Profils:", error);
}
console.log(profile.value) console.log(profile.value)
setPageLayout(platformIsNative.value ? 'mobile' : 'default') setPageLayout(platformIsNative.value ? 'mobile' : 'default')
} }
// 💡 ANGEPASST: Ruft den neuen Endpunkt ab und speichert das gesamte Payload-Objekt
async function loadWorkingTimeInfo() { async function loadWorkingTimeInfo() {
workingTimeInfo.value = await useFunctions().getWorkingTimesEvaluationData(
route.params.id, // Erstellt Query-Parameter für den neuen Backend-Endpunkt
selectedStartDay.value, const queryParams = new URLSearchParams({
selectedEndDay.value from: selectedStartDay.value,
) to: selectedEndDay.value,
targetUserId: evaluatedUserId.value,
});
const url = `/api/staff/time/evaluation?${queryParams.toString()}`;
// Führt den GET-Request zum neuen Endpunkt aus und speichert das gesamte Payload-Objekt { userId, spans, summary }
const data = await useNuxtApp().$api(url);
workingTimeInfo.value = data;
openTab.value = 0 openTab.value = 0
} }
// 📄 PDF generieren // 📄 PDF generieren
// Frontend (index.vue)
async function generateDocument() { async function generateDocument() {
const path = (await useEntities("letterheads").select("*"))[0].path // TODO SELECT if (!workingTimeInfo.value || !workingTimeInfo.value.summary) return;
const path = (await useEntities("letterheads").select("*"))[0].path
uri.value = await useFunctions().useCreatePDF({ uri.value = await useFunctions().useCreatePDF({
full_name: profile.value.full_name, full_name: profile.value.full_name,
employee_number: profile.value.employee_number ? profile.value.employee_number : "-", employee_number: profile.value.employee_number || "-",
...workingTimeInfo.value}, path, "timesheet")
// Wir übergeben das summary-Objekt flach (für Header-Daten)
...workingTimeInfo.value.summary,
// UND wir müssen die Spans explizit übergeben, damit die Tabelle generiert werden kann
spans: workingTimeInfo.value.spans
}, path, "timesheet")
showDocument.value = true showDocument.value = true
} }
const fileSaved = ref(false) const fileSaved = ref(false)
// 💾 Datei speichern
async function saveFile() { async function saveFile() {
try { try {
let fileData = { let fileData = {
@@ -107,9 +174,6 @@ async function saveFile() {
} catch (error) { } catch (error) {
toast.add({title:"Fehler beim Speichern der Auswertung", color: "rose"}) toast.add({title:"Fehler beim Speichern der Auswertung", color: "rose"})
} }
} }
async function onTabChange(index: number) { async function onTabChange(index: number) {
@@ -118,7 +182,6 @@ async function onTabChange(index: number) {
// Initialisierung // Initialisierung
await setupPage() await setupPage()
changeRange()
</script> </script>
<template> <template>
@@ -206,22 +269,24 @@ changeRange()
> >
<template #item="{ item }"> <template #item="{ item }">
<div v-if="item.label === 'Information'"> <div v-if="item.label === 'Information'">
<UCard v-if="workingTimeInfo" class="my-5">
<UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="my-5">
<template #header> <template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3> <h3 class="text-base font-semibold">Zusammenfassung</h3>
</template> </template>
<div class="grid grid-cols-2 gap-3 text-sm"> <div class="grid grid-cols-2 gap-3 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesEingereicht) }}</b></p> <p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesApproved) }}</b></p> <p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
<p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.sumRecreationDays }} Tage</p> <p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.summary.sumRecreationDays }} Tage</p>
<p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.sumVacationDays }} Tage</p> <p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.summary.sumVacationDays }} Tage</p>
<p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.sumSickDays }} Tage</p> <p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.summary.sumSickDays }} Tage</p>
<p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.timeSpanWorkingMinutes) }}</b></p> <p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
<p class="col-span-2"> <p class="col-span-2">
Inoffizielles Saldo: <b>{{ (workingTimeInfo.saldoInOfficial >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldoInOfficial)) }}</b> Inoffizielles Saldo: <b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
</p> </p>
<p class="col-span-2"> <p class="col-span-2">
Saldo: <b>{{ (workingTimeInfo.saldo >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldo)) }}</b> Saldo: <b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
</p> </p>
</div> </div>
</UCard> </UCard>
@@ -229,33 +294,39 @@ changeRange()
<UDashboardPanel> <UDashboardPanel>
<UTable <UTable
v-if="workingTimeInfo" v-if="workingTimeInfo"
:rows="workingTimeInfo.times" :rows="workingTimeInfo.spans"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }" :empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
:columns="[ :columns="[
{ key: 'state', label: 'Status' }, { key: 'status', label: 'Status' },
{ key: 'start', label: 'Start' }, { key: 'startedAt', label: 'Start' },
{ key: 'end', label: 'Ende' }, { key: 'endedAt', label: 'Ende' },
{ key: 'duration', label: 'Dauer' }, { key: 'duration', label: 'Dauer' },
{ key: 'description', label: 'Beschreibung' } { key: 'type', label: 'Typ' }
]" ]"
@select="(row) => router.push(`/workingtimes/edit/${row.id}`)" @select="(row) => router.push(`/workingtimes/edit/${row.sourceEventIds[0]}`)"
> >
<template #state-data="{row}"> <template #status-data="{row}">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span> <span v-if="row.status === 'approved'" class="text-primary-500">Genehmigt</span>
<span v-else-if="row.state === 'submitted'" class="text-cyan-500">Eingereicht</span> <span v-else-if="row.status === 'submitted'" class="text-cyan-500">Eingereicht</span>
<span v-else-if="row.state === 'draft'" class="text-red-500">Entwurf</span> <span v-else-if="row.status === 'factual'" class="text-gray-500">Faktisch</span>
<span v-else-if="row.status === 'draft'" class="text-red-500">Entwurf</span>
<span v-else>{{ row.status }}</span>
</template> </template>
<template #start-data="{ row }"> <template #startedAt-data="{ row }">
{{ $dayjs(row.started_at).format('HH:mm DD.MM.YY') }} Uhr {{ $dayjs(row.startedAt).format('HH:mm DD.MM.YY') }} Uhr
</template> </template>
<template #end-data="{ row }"> <template #endedAt-data="{ row }">
{{ $dayjs(row.stopped_at).format('HH:mm DD.MM.YY') }} Uhr {{ $dayjs(row.endedAt).format('HH:mm DD.MM.YY') }} Uhr
</template> </template>
<template #duration-data="{ row }"> <template #duration-data="{ row }">
{{ useFormatDuration(row.duration_minutes) }} {{ formatSpanDuration(row.startedAt, row.endedAt) }}
</template>
<template #type-data="{ row }">
{{ row.type.charAt(0).toUpperCase() + row.type.slice(1).replace('_', ' ') }}
</template> </template>
</UTable> </UTable>
</UDashboardPanel> </UDashboardPanel>
@@ -273,12 +344,8 @@ changeRange()
</UDashboardPanelContent> </UDashboardPanelContent>
</template> </template>
<!-- ====================== -->
<!-- 📱 MOBILE ANSICHT -->
<!-- ====================== -->
<template v-else> <template v-else>
<!-- 🔙 Navigation -->
<UDashboardNavbar title="Auswertung"> <UDashboardNavbar title="Auswertung">
<template #toggle><div></div></template> <template #toggle><div></div></template>
<template #left> <template #left>
@@ -290,9 +357,7 @@ changeRange()
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<!-- 📌 Mobile Zeitraumwahl -->
<div class="p-4 space-y-4 border-b bg-white dark:bg-gray-900"> <div class="p-4 space-y-4 border-b bg-white dark:bg-gray-900">
<!-- Predefined Ranges -->
<USelectMenu <USelectMenu
v-model="selectedPresetRange" v-model="selectedPresetRange"
:options="[ :options="[
@@ -309,7 +374,6 @@ changeRange()
class="w-full" class="w-full"
/> />
<!-- Start/End Datum -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<p class="text-xs text-gray-500 mb-1">Start</p> <p class="text-xs text-gray-500 mb-1">Start</p>
@@ -341,7 +405,6 @@ changeRange()
</div> </div>
</div> </div>
<!-- 📑 Mobile Tabs -->
<UTabs <UTabs
:items="[{ label: 'Information' }, { label: 'Bericht' }]" :items="[{ label: 'Information' }, { label: 'Bericht' }]"
v-model="openTab" v-model="openTab"
@@ -350,58 +413,50 @@ changeRange()
> >
<template #item="{ item }"> <template #item="{ item }">
<!-- ====================== -->
<!-- TAB 1 INFORMATION -->
<!-- ====================== -->
<div v-if="item.label === 'Information'" class="space-y-4"> <div v-if="item.label === 'Information'" class="space-y-4">
<!-- Summary Card --> <UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="mt-3">
<UCard v-if="workingTimeInfo" class="mt-3">
<template #header> <template #header>
<h3 class="text-base font-semibold">Zusammenfassung</h3> <h3 class="text-base font-semibold">Zusammenfassung</h3>
</template> </template>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesEingereicht) }}</b></p> <p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesApproved) }}</b></p> <p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
<p> <p>
Feiertagsausgleich: Feiertagsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesRecreationDays) }}</b> <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b>
/ {{ workingTimeInfo.sumRecreationDays }} Tage / {{ workingTimeInfo.summary.sumRecreationDays }} Tage
</p> </p>
<p> <p>
Urlaubs-/Berufsschule: Urlaubs-/Berufsschule:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesVacationDays) }}</b> <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b>
/ {{ workingTimeInfo.sumVacationDays }} Tage / {{ workingTimeInfo.summary.sumVacationDays }} Tage
</p> </p>
<p> <p>
Krankheitsausgleich: Krankheitsausgleich:
<b>{{ formatMinutesToHHMM(workingTimeInfo.sumWorkingMinutesSickDays) }}</b> <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b>
/ {{ workingTimeInfo.sumSickDays }} Tage / {{ workingTimeInfo.summary.sumSickDays }} Tage
</p> </p>
<p>Soll: <b>{{ formatMinutesToHHMM(workingTimeInfo.timeSpanWorkingMinutes) }}</b></p> <p>Soll: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
<p> <p>
Inoffizielles Saldo: Inoffizielles Saldo:
<b>{{ (workingTimeInfo.saldoInOfficial >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldoInOfficial)) }}</b> <b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
</p> </p>
<p> <p>
Saldo: Saldo:
<b>{{ (workingTimeInfo.saldo >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.saldo)) }}</b> <b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
</p> </p>
</div> </div>
</UCard> </UCard>
</div> </div>
<!-- ====================== -->
<!-- TAB 2 BERICHT -->
<!-- ====================== -->
<div v-else-if="item.label === 'Bericht'"> <div v-else-if="item.label === 'Bericht'">
<UButton <UButton
v-if="uri && !fileSaved" v-if="uri && !fileSaved"

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup>
import { useStaffTime } from '~/composables/useStaffTime' import { useStaffTime } from '~/composables/useStaffTime'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/stores/auth'
import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue"; import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue";
@@ -12,20 +12,30 @@ const auth = useAuthStore()
const router = useRouter() const router = useRouter()
// MOBILE DETECTION // MOBILE DETECTION
const platformIsNative = useCapacitor().getIsNative() const platformIsNative = useCapacitor().getIsNative()
// LIST + ACTIVE
const entries = ref([])
const active = computed(() => entries.value.find(e => !e.stopped_at && e.user_id === auth.user.id))
// STATE
const loading = ref(false) const loading = ref(false)
const showModal = ref(false) const showModal = ref(false)
const editEntry = ref(null) const editEntry = ref(null)
// 👥 Nutzer-Filter (nur für Berechtigte) // 👥 Nutzer-Filter
const users = ref([]) const users = ref([])
const selectedUser = ref(platformIsNative ? auth.user.id : null) // WICHTIG: Standardmäßig IMMER den aktuellen User wählen (kein null mehr)
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
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))
// Prüft, ob wir gerade das eigene Profil ansehen (für Start/Stop Buttons)
const isViewingSelf = computed(() => selectedUser.value === auth.user.id)
const typeLabel = { const typeLabel = {
work: "Arbeitszeit", work: "Arbeitszeit",
vacation: "Urlaub", vacation: "Urlaub",
@@ -45,21 +55,23 @@ const typeColor = {
async function loadUsers() { async function loadUsers() {
if (!canViewAll.value) return if (!canViewAll.value) return
// Beispiel: User aus Supabase holen
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)
// LOAD ENTRIES (only own entries on mobile)
async function load() { async function load() {
entries.value = await list( if (!selectedUser.value) return
canViewAll.value && selectedUser.value ? { user_id: selectedUser.value } : undefined
)
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
loading.value = true loading.value = true
await start("Arbeitszeit gestartet") await start("Arbeitszeit gestartet")
await load() await load()
@@ -68,73 +80,93 @@ 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()
loading.value = false loading.value = false
} }
function handleEdit(entry: any) { function handleEdit(entry) {
editEntry.value = entry editEntry.value = entry
showModal.value = true showModal.value = true
} }
async function handleSubmit(entry: any) { async function handleSubmit(entry) {
await submit(entry.id) await submit(entry)
await load() await load()
} }
async function handleApprove(entry: any) { async function handleApprove(entry) {
await approve(entry.id) await approve(entry)
await load() await load()
} }
// Watcher: Wenn der User im Dropdown gewechselt wird, neu laden
watch(selectedUser, () => {
onMounted(async () => { load()
await load()
await loadUsers()
setPageLayout(platformIsNative ? 'mobile' : 'default')
}) })
onMounted(async () => {
await loadUsers()
await load() // Lädt initial den eingeloggten User
setPageLayout(platformIsNative ? 'mobile' : 'default')
})
</script> </script>
<template> <template>
<!-- ============================= -->
<!-- DESKTOP VERSION -->
<!-- ============================= -->
<template v-if="!platformIsNative"> <template v-if="!platformIsNative">
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" /> <UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
<UDashboardToolbar> <!--<UDashboardToolbar>
<template #left> <template #left>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UIcon name="i-heroicons-clock" class="text-primary-500" /> <UIcon name="i-heroicons-clock" class="text-primary-500" />
<span v-if="active" class="text-primary-600 font-medium"> <span v-if="active" class="text-primary-600 font-medium">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }} <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> </span>
<span v-else class="text-gray-500">Keine aktive Zeit</span>
</div> </div>
</template> </template>
<template #right> <template #right>
<UButton <template v-if="isViewingSelf">
v-if="active" <UButton
color="red" v-if="active"
icon="i-heroicons-stop" color="red"
:loading="loading" icon="i-heroicons-stop"
label="Stoppen" :loading="loading"
@click="handleStop" label="Stoppen"
/> @click="handleStop"
<UButton />
v-else <UButton
color="green" v-else
icon="i-heroicons-play" color="green"
:loading="loading" icon="i-heroicons-play"
label="Starten" :loading="loading"
@click="handleStart" 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 <UButton
color="primary" color="primary"
icon="i-heroicons-plus" icon="i-heroicons-plus"
@@ -142,28 +174,22 @@ onMounted(async () => {
@click="() => { editEntry = null; showModal = true }" @click="() => { editEntry = null; showModal = true }"
/> />
</template> </template>
</UDashboardToolbar> </UDashboardToolbar>-->
<UDashboardToolbar> <UDashboardToolbar>
<template #left> <template #left>
<!-- 👥 User-Filter (nur bei Berechtigung) -->
<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"
:options="[ :options="users.map(u => ({ label: u.full_name || u.email, value: u.user_id }))"
{ label: 'Alle Benutzer', value: null },
...users.map(u => ({ label: u.full_name || u.email, value: u.user_id }))
]"
placeholder="Benutzer auswählen" placeholder="Benutzer auswählen"
value-attribute="value" value-attribute="value"
option-attribute="label" option-attribute="label"
class="min-w-[220px]" class="min-w-[220px]"
@change="load" :clearable="false"
/> />
<!-- 🔹 Button zur Auswertung --> <UTooltip text="Anwesenheiten auswerten">
<UTooltip
:text="selectedUser ? 'Anwesenheiten des Mitarbeiters auswerten' : 'Mitarbeiter für die Auswertung auswählen'"
>
<UButton <UButton
:disabled="!selectedUser" :disabled="!selectedUser"
color="gray" color="gray"
@@ -188,10 +214,11 @@ onMounted(async () => {
{ 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' }, // { 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' }"
> >
<template #state-data="{ row }"> <template #state-data="{ row }">
<span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span> <span v-if="row.state === 'approved'" class="text-primary-500">Genehmigt</span>
@@ -204,53 +231,37 @@ onMounted(async () => {
</UBadge> </UBadge>
</template> </template>
<!-- START -->
<template #started_at-data="{ row }"> <template #started_at-data="{ row }">
<!-- Urlaub / Krankheit nur Tag -->
<span v-if="row.type === 'vacation' || row.type === 'sick'"> <span v-if="row.type === 'vacation' || row.type === 'sick'">
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }} {{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}
</span> </span>
<!-- Arbeitszeit / andere Datum + Uhrzeit -->
<span v-else> <span v-else>
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }} {{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}
</span> </span>
</template> </template>
<!-- ENDE -->
<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">
läuft... läuft...
</span> </span>
<!-- Urlaub / Krankheit nur Tag -->
<span v-else-if="row.type === 'vacation' || row.type === 'sick'"> <span v-else-if="row.type === 'vacation' || row.type === 'sick'">
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }} {{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}
</span> </span>
<!-- Arbeitszeit / andere Datum + Uhrzeit -->
<span v-else> <span v-else>
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }} {{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}
</span> </span>
</template> </template>
<template #duration_minutes-data="{ row }"> <template #duration_minutes-data="{ row }">
<!-- Urlaub / Krankheit Tage anzeigen -->
<span v-if="row.type === 'vacation' || row.type === 'sick'"> <span v-if="row.type === 'vacation' || row.type === 'sick'">
<!-- {{ useFormatDurationDays(row.startet_at, row.stopped_at) }}-->-- </span>
</span>
<!-- Arbeitszeit / andere Minutenformat -->
<span v-else> <span v-else>
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }} {{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
</span> </span>
</template> </template>
<template #actions-data="{ row }"> <template #actions-data="{ row }">
<UTooltip text="Zeit genehmigen" v-if="row.state === 'submitted'"> <UTooltip text="Zeit genehmigen" v-if="row.state === 'submitted' && canViewAll">
<UButton <UButton
variant="ghost" variant="ghost"
icon="i-heroicons-check-circle" icon="i-heroicons-check-circle"
@@ -272,9 +283,7 @@ onMounted(async () => {
/> />
</UTooltip> </UTooltip>
</template> </template>
<template #user-data="{ row }">
{{users.find(i => i.user_id === row.user_id) ? users.find(i => i.user_id === row.user_id).full_name : ""}}
</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>
@@ -284,22 +293,16 @@ onMounted(async () => {
</UDashboardPanelContent> </UDashboardPanelContent>
</template> </template>
<!-- ============================= -->
<!-- MOBILE VERSION -->
<!-- ============================= -->
<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">
<!-- 🔥 FIXED ACTIVE TIMER --> <div v-if="isViewingSelf" class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
<div 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">
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }} Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
</p> </p>
@@ -328,17 +331,15 @@ onMounted(async () => {
<UButton <UButton
color="gray" color="gray"
icon="i-heroicons-chart-bar" icon="i-heroicons-chart-bar"
label="Eigene Auswertung" label="Auswertung"
class="w-full" class="w-full"
variant="soft" variant="soft"
@click="router.push(`/staff/time/${auth.user.id}/evaluate`)" @click="router.push(`/staff/time/${selectedUser}/evaluate`)"
/> />
</div> </div>
<!-- 📜 SCROLLABLE CONTENT -->
<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">
<!-- ZEIT-CARDS -->
<UCard <UCard
v-for="row in entries" v-for="row in entries"
:key="row.id" :key="row.id"
@@ -349,30 +350,16 @@ onMounted(async () => {
<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>{{ row.description || 'Keine Beschreibung' }}</span>
<UBadge <UBadge :color="typeColor[row.type]" class="text-xs">
:color="typeColor[row.type]"
class="text-xs"
>
{{ typeLabel[row.type] }} {{ typeLabel[row.type] }}
</UBadge> </UBadge>
</div> </div>
<UBadge <UBadge
:color="{ :color="{ approved: 'primary', submitted: 'cyan', draft: 'red' }[row.state]"
approved: 'primary',
submitted: 'cyan',
draft: 'red'
}[row.state]"
> >
{{ {{ { approved: 'Genehmigt', submitted: 'Eingereicht', draft: 'Entwurf' }[row.state] || row.state }}
{
approved: 'Genehmigt',
submitted: 'Eingereicht',
draft: 'Entwurf'
}[row.state] || row.state
}}
</UBadge> </UBadge>
</div> </div>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
@@ -382,8 +369,8 @@ onMounted(async () => {
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Ende: Ende:
<span v-if="row.stopped_at"> <span v-if="row.stopped_at">
{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }} {{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}
</span> </span>
<span v-else class="text-primary-500 font-medium">läuft...</span> <span v-else class="text-primary-500 font-medium">läuft...</span>
</p> </p>
@@ -392,7 +379,6 @@ onMounted(async () => {
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : '-' }} {{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : '-' }}
</p> </p>
<!-- ACTION-BUTTONS -->
<div class="flex gap-2 mt-3"> <div class="flex gap-2 mt-3">
<UButton <UButton
v-if="row.state === 'draft'" v-if="row.state === 'draft'"
@@ -402,21 +388,18 @@ onMounted(async () => {
variant="soft" variant="soft"
@click.stop="handleSubmit(row)" @click.stop="handleSubmit(row)"
/> />
<UButton
<!-- <UButton v-if="row.state === 'submitted' && canViewAll"
v-if="row.state === 'submitted'"
color="primary" color="primary"
icon="i-heroicons-check" icon="i-heroicons-check"
label="Genehmigen" label="Genehmigen"
variant="soft" variant="soft"
@click.stop="handleApprove(row)" @click.stop="handleApprove(row)"
/>--> />
</div> </div>
</UCard> </UCard>
</UDashboardPanelContent> </UDashboardPanelContent>
<!-- FLOATING ACTION BUTTON -->
<FloatingActionButton <FloatingActionButton
icon="i-heroicons-plus" icon="i-heroicons-plus"
class="!fixed bottom-6 right-6 z-50" class="!fixed bottom-6 right-6 z-50"
@@ -427,7 +410,6 @@ onMounted(async () => {
</div> </div>
</template> </template>
<!-- MODAL -->
<StaffTimeEntryModal <StaffTimeEntryModal
v-model="showModal" v-model="showModal"
:entry="editEntry" :entry="editEntry"