Added Frontend
This commit is contained in:
318
frontend/pages/staff/profiles/[id].vue
Normal file
318
frontend/pages/staff/profiles/[id].vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const { $api } = useNuxtApp()
|
||||
|
||||
const id = route.params.id as string
|
||||
const profile = ref<any>(null)
|
||||
const pending = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
/** Profil laden **/
|
||||
async function fetchProfile() {
|
||||
pending.value = true
|
||||
try {
|
||||
profile.value = await $api(`/api/profiles/${id}`)
|
||||
ensureWorkingHoursStructure()
|
||||
} catch (err: any) {
|
||||
console.error('[fetchProfile]', err)
|
||||
toast.add({
|
||||
title: 'Fehler beim Laden',
|
||||
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Profil speichern **/
|
||||
async function saveProfile() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await $api(`/api/profiles/${id}`, {
|
||||
method: 'PUT',
|
||||
body: profile.value
|
||||
})
|
||||
toast.add({ title: 'Profil gespeichert', color: 'green' })
|
||||
fetchProfile()
|
||||
} catch (err: any) {
|
||||
console.error('[saveProfile]', err)
|
||||
toast.add({
|
||||
title: 'Fehler beim Speichern',
|
||||
description: err?.data?.error || err?.message || 'Unbekannter Fehler',
|
||||
color: 'red'
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const weekdays = [
|
||||
{ key: '1', label: 'Montag' },
|
||||
{ key: '2', label: 'Dienstag' },
|
||||
{ key: '3', label: 'Mittwoch' },
|
||||
{ key: '4', label: 'Donnerstag' },
|
||||
{ key: '5', label: 'Freitag' },
|
||||
{ key: '6', label: 'Samstag' },
|
||||
{ key: '7', label: 'Sonntag' }
|
||||
]
|
||||
|
||||
const bundeslaender = [
|
||||
{ code: 'DE-BW', name: 'Baden-Württemberg' },
|
||||
{ code: 'DE-BY', name: 'Bayern' },
|
||||
{ code: 'DE-BE', name: 'Berlin' },
|
||||
{ code: 'DE-BB', name: 'Brandenburg' },
|
||||
{ code: 'DE-HB', name: 'Bremen' },
|
||||
{ code: 'DE-HH', name: 'Hamburg' },
|
||||
{ code: 'DE-HE', name: 'Hessen' },
|
||||
{ code: 'DE-MV', name: 'Mecklenburg-Vorpommern' },
|
||||
{ code: 'DE-NI', name: 'Niedersachsen' },
|
||||
{ code: 'DE-NW', name: 'Nordrhein-Westfalen' },
|
||||
{ code: 'DE-RP', name: 'Rheinland-Pfalz' },
|
||||
{ code: 'DE-SL', name: 'Saarland' },
|
||||
{ code: 'DE-SN', name: 'Sachsen' },
|
||||
{ code: 'DE-ST', name: 'Sachsen-Anhalt' },
|
||||
{ code: 'DE-SH', name: 'Schleswig-Holstein' },
|
||||
{ code: 'DE-TH', name: 'Thüringen' }
|
||||
]
|
||||
|
||||
|
||||
// Sicherstellen, dass das JSON-Feld existiert
|
||||
function ensureWorkingHoursStructure() {
|
||||
if (!profile.value.weekly_regular_working_hours) {
|
||||
profile.value.weekly_regular_working_hours = {}
|
||||
}
|
||||
for (const { key } of weekdays) {
|
||||
if (profile.value.weekly_regular_working_hours[key] == null) {
|
||||
profile.value.weekly_regular_working_hours[key] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recalculateWeeklyHours() {
|
||||
if (!profile.value?.weekly_regular_working_hours) return
|
||||
|
||||
const total = Object.values(profile.value.weekly_regular_working_hours).reduce(
|
||||
(sum: number, val: any) => {
|
||||
const num = parseFloat(val)
|
||||
return sum + (isNaN(num) ? 0 : num)
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
profile.value.weekly_working_hours = Number(total.toFixed(2))
|
||||
}
|
||||
|
||||
const checkZip = async () => {
|
||||
const zipData = await useFunctions().useZipCheck(profile.value.address_zip)
|
||||
profile.value.address_city = zipData.short
|
||||
profile.value.state_code = zipData.state_code
|
||||
}
|
||||
|
||||
onMounted(fetchProfile)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Haupt-Navigation -->
|
||||
<UDashboardNavbar title="Mitarbeiter">
|
||||
<template #left>
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="outline"
|
||||
@click="navigateTo(`/staff/profiles`)"
|
||||
icon="i-heroicons-chevron-left"
|
||||
|
||||
>
|
||||
Mitarbeiter
|
||||
</UButton>
|
||||
</template>
|
||||
<template #center>
|
||||
<h1 class="text-xl font-medium truncate">
|
||||
Mitarbeiter bearbeiten: {{ profile?.full_name || '' }}
|
||||
</h1>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<UDashboardToolbar>
|
||||
<template #right>
|
||||
<UButton
|
||||
icon="i-mdi-content-save"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
@click="saveProfile"
|
||||
>
|
||||
Speichern
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<!-- Inhalt -->
|
||||
<UDashboardPanelContent>
|
||||
<UCard v-if="!pending && profile">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<UAvatar size="xl" :alt="profile.full_name" />
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">{{ profile.full_name }}</h2>
|
||||
<p class="text-sm text-gray-500">{{ profile.employee_number || '–' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UDivider label="Persönliche Daten" />
|
||||
|
||||
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormGroup label="Vorname">
|
||||
<UInput v-model="profile.first_name" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Nachname">
|
||||
<UInput v-model="profile.last_name" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="E-Mail">
|
||||
<UInput v-model="profile.email" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Telefon (Mobil)">
|
||||
<UInput v-model="profile.mobile_tel" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Telefon (Festnetz)">
|
||||
<UInput v-model="profile.fixed_tel" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Geburtstag">
|
||||
<UInput type="date" v-model="profile.birthday" />
|
||||
</UFormGroup>
|
||||
</UForm>
|
||||
</UCard>
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<UDivider label="Vertragsinformationen" />
|
||||
|
||||
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormGroup label="Vertragsart">
|
||||
<UInput v-model="profile.contract_type"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Status">
|
||||
<UInput v-model="profile.status"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Position">
|
||||
<UInput v-model="profile.position"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Qualifikation">
|
||||
<UInput v-model="profile.qualification"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Eintrittsdatum">
|
||||
<UInput type="date" v-model="profile.entry_date" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Wöchentliche Arbeitszeit (Std)">
|
||||
<UInput type="number" v-model="profile.weekly_working_hours" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Bezahlte Urlaubstage (Jahr)">
|
||||
<UInput type="number" v-model="profile.annual_paid_leave_days" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Aktiv">
|
||||
<div class="flex items-center gap-3">
|
||||
<UToggle v-model="profile.active" color="primary" />
|
||||
<span class="text-sm text-gray-600">
|
||||
</span>
|
||||
</div>
|
||||
</UFormGroup>
|
||||
</UForm>
|
||||
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<UDivider label="Adresse & Standort" />
|
||||
|
||||
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormGroup label="Straße und Hausnummer">
|
||||
<UInput v-model="profile.address_street"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="PLZ">
|
||||
<UInput type="text" v-model="profile.address_zip" @focusout="checkZip"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Ort">
|
||||
<UInput v-model="profile.address_city"/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Bundesland">
|
||||
<USelectMenu
|
||||
v-model="profile.state_code"
|
||||
:options="bundeslaender"
|
||||
value-attribute="code"
|
||||
option-attribute="name"
|
||||
placeholder="Bundesland auswählen"
|
||||
/>
|
||||
</UFormGroup>
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<UDivider label="Wöchentliche Arbeitsstunden" />
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="day in weekdays"
|
||||
:key="day.key"
|
||||
:class="[...profile.weekly_regular_working_hours[day.key] === 0 ? ['bg-gray-100'] : ['bg-gray-100','border-green-400'], 'flex items-center justify-between border rounded-lg p-3 bg-gray-50']"
|
||||
>
|
||||
<span class="font-medium text-gray-700">{{ day.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
type="number"
|
||||
size="sm"
|
||||
min="0"
|
||||
max="24"
|
||||
step="0.25"
|
||||
v-model.number="profile.weekly_regular_working_hours[day.key]"
|
||||
placeholder="0"
|
||||
class="w-24"
|
||||
@change="recalculateWeeklyHours"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">Std</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="!pending && profile" class="mt-3">
|
||||
<UDivider label="Sonstiges" />
|
||||
<UForm :state="profile" @submit.prevent="saveProfile" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
<UFormGroup label="Kleidergröße (Oberteil)">
|
||||
<UInput v-model="profile.clothing_size_top" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Kleidergröße (Hose)">
|
||||
<UInput v-model="profile.clothing_size_bottom" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Schuhgröße">
|
||||
<UInput v-model="profile.clothing_size_shoe" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Token-ID">
|
||||
<UInput v-model="profile.token_id" />
|
||||
</UFormGroup>
|
||||
</UForm>
|
||||
</UCard>
|
||||
|
||||
<USkeleton v-if="pending" height="300px" />
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
|
||||
|
||||
52
frontend/pages/staff/profiles/index.vue
Normal file
52
frontend/pages/staff/profiles/index.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
|
||||
const setupPage = async () => {
|
||||
items.value = (await useNuxtApp().$api("/api/tenant/users")).users
|
||||
items.value = items.value.map(i => i.profile)
|
||||
}
|
||||
|
||||
setupPage()
|
||||
|
||||
const templateColumns = [
|
||||
{
|
||||
key: 'employee_number',
|
||||
label: "MA-Nummer",
|
||||
},{
|
||||
key: 'full_name',
|
||||
label: "Name",
|
||||
},{
|
||||
key: "email",
|
||||
label: "E-Mail",
|
||||
}
|
||||
]
|
||||
const selectedColumns = ref(templateColumns)
|
||||
const columns = computed(() => templateColumns.filter((column) => selectedColumns.value.includes(column)))
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardNavbar title="Benutzer Einstellungen">
|
||||
<template #right>
|
||||
<UButton
|
||||
@click="router.push(`/profiles/create`)"
|
||||
disabled
|
||||
>
|
||||
+ Mitarbeiter
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<UTable
|
||||
:rows="items"
|
||||
:columns="columns"
|
||||
@select="(i) => navigateTo(`/staff/profiles/${i.id}`)"
|
||||
>
|
||||
|
||||
</UTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
482
frontend/pages/staff/time/[id]/evaluate.vue
Normal file
482
frontend/pages/staff/time/[id]/evaluate.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
const { $dayjs } = useNuxtApp()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
// 🔹 State
|
||||
const workingTimeInfo = ref<{
|
||||
userId: string;
|
||||
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 selectedPresetRange = ref("Dieser Monat bis heute")
|
||||
const selectedStartDay = ref("")
|
||||
const selectedEndDay = ref("")
|
||||
const openTab = ref(0)
|
||||
|
||||
const showDocument = ref(false)
|
||||
const uri = 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) {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = Math.floor(minutes % 60)
|
||||
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
|
||||
function changeRange() {
|
||||
const rangeMap = {
|
||||
"Diese Woche": { selector: "isoWeek", subtract: 0 },
|
||||
"Dieser Monat": { selector: "M", subtract: 0 },
|
||||
"Dieser Monat bis heute": { selector: "M", subtract: 0 },
|
||||
"Dieses Jahr": { selector: "y", subtract: 0 },
|
||||
"Letzte Woche": { selector: "isoWeek", subtract: 1 },
|
||||
"Letzter Monat": { selector: "M", subtract: 1 },
|
||||
"Letztes Jahr": { selector: "y", subtract: 1 }
|
||||
}
|
||||
|
||||
const { selector, subtract } = rangeMap[selectedPresetRange.value] || { selector: "M", subtract: 0 }
|
||||
|
||||
selectedStartDay.value = $dayjs()
|
||||
.subtract(subtract, selector === "isoWeek" ? "week" : selector)
|
||||
.startOf(selector)
|
||||
.format("YYYY-MM-DD")
|
||||
|
||||
selectedEndDay.value =
|
||||
selectedPresetRange.value === "Dieser Monat bis heute"
|
||||
? $dayjs().format("YYYY-MM-DD")
|
||||
: $dayjs()
|
||||
.subtract(subtract, selector === "isoWeek" ? "week" : selector)
|
||||
.endOf(selector)
|
||||
.format("YYYY-MM-DD")
|
||||
|
||||
loadWorkingTimeInfo()
|
||||
}
|
||||
|
||||
// 📊 Daten laden (Initialisierung)
|
||||
async function setupPage() {
|
||||
await changeRange()
|
||||
|
||||
// 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)
|
||||
setPageLayout(platformIsNative.value ? 'mobile' : 'default')
|
||||
}
|
||||
|
||||
// 💡 ANGEPASST: Ruft den neuen Endpunkt ab und speichert das gesamte Payload-Objekt
|
||||
async function loadWorkingTimeInfo() {
|
||||
|
||||
// Erstellt Query-Parameter für den neuen Backend-Endpunkt
|
||||
const queryParams = new URLSearchParams({
|
||||
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
|
||||
}
|
||||
|
||||
// 📄 PDF generieren
|
||||
// Frontend (index.vue)
|
||||
async function generateDocument() {
|
||||
if (!workingTimeInfo.value || !workingTimeInfo.value.summary) return;
|
||||
|
||||
const path = (await useEntities("letterheads").select("*"))[0].path
|
||||
|
||||
uri.value = await useFunctions().useCreatePDF({
|
||||
full_name: profile.value.full_name,
|
||||
employee_number: profile.value.employee_number || "-",
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const fileSaved = ref(false)
|
||||
|
||||
// 💾 Datei speichern
|
||||
async function saveFile() {
|
||||
try {
|
||||
let fileData = {
|
||||
auth_profile: profile.value.id,
|
||||
tenant: auth.activeTenant
|
||||
}
|
||||
|
||||
let file = useFiles().dataURLtoFile(uri.value, `${profile.value.full_name}-${$dayjs(selectedStartDay.value).format("YYYY-MM-DD")}-${$dayjs(selectedEndDay.value).format("YYYY-MM-DD")}.pdf`)
|
||||
|
||||
await useFiles().uploadFiles(fileData, [file])
|
||||
|
||||
toast.add({title:"Auswertung erfolgreich gespeichert"})
|
||||
fileSaved.value = true
|
||||
} catch (error) {
|
||||
toast.add({title:"Fehler beim Speichern der Auswertung", color: "rose"})
|
||||
}
|
||||
}
|
||||
|
||||
async function onTabChange(index: number) {
|
||||
if (index === 1) await generateDocument()
|
||||
}
|
||||
|
||||
// Initialisierung
|
||||
await setupPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!platformIsNative">
|
||||
<UDashboardNavbar :ui="{ center: 'flex items-stretch gap-1.5 min-w-0' }">
|
||||
<template #left>
|
||||
<UButton
|
||||
icon="i-heroicons-chevron-left"
|
||||
variant="outline"
|
||||
@click="router.push('/staff/time')"
|
||||
>
|
||||
Anwesenheiten
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<h1 class="text-xl font-medium truncate">
|
||||
Auswertung Anwesenheiten: {{ profile?.full_name || '' }}
|
||||
</h1>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<UDashboardToolbar>
|
||||
<template #left>
|
||||
<UFormGroup label="Zeitraum:">
|
||||
<USelectMenu
|
||||
:options="[
|
||||
'Dieser Monat bis heute',
|
||||
'Diese Woche',
|
||||
'Dieser Monat',
|
||||
'Dieses Jahr',
|
||||
'Letzte Woche',
|
||||
'Letzter Monat',
|
||||
'Letztes Jahr'
|
||||
]"
|
||||
v-model="selectedPresetRange"
|
||||
@change="changeRange"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Start:">
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UButton
|
||||
icon="i-heroicons-calendar-days-20-solid"
|
||||
:label="selectedStartDay ? $dayjs(selectedStartDay).format('DD.MM.YYYY') : 'Datum wählen'"
|
||||
/>
|
||||
<template #panel="{ close }">
|
||||
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Ende:">
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UButton
|
||||
icon="i-heroicons-calendar-days-20-solid"
|
||||
:label="selectedEndDay ? $dayjs(selectedEndDay).format('DD.MM.YYYY') : 'Datum wählen'"
|
||||
/>
|
||||
<template #panel="{ close }">
|
||||
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormGroup>
|
||||
</template>
|
||||
<template #right>
|
||||
<UTooltip
|
||||
:text="fileSaved ? 'Bericht bereits gespeichert' : 'Bericht speichern'"
|
||||
v-if="openTab === 1 && uri"
|
||||
>
|
||||
<UButton
|
||||
icon="i-mdi-content-save"
|
||||
:disabled="fileSaved"
|
||||
@click="saveFile"
|
||||
>Bericht</UButton>
|
||||
</UTooltip>
|
||||
|
||||
</template>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<UDashboardPanelContent>
|
||||
<UTabs
|
||||
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
|
||||
v-model="openTab"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="item.label === 'Information'">
|
||||
|
||||
<UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="my-5">
|
||||
<template #header>
|
||||
<h3 class="text-base font-semibold">Zusammenfassung</h3>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
|
||||
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
|
||||
<p>Feiertagsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b> / {{ workingTimeInfo.summary.sumRecreationDays }} Tage</p>
|
||||
<p>Urlaubs-/Berufsschulausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b> / {{ workingTimeInfo.summary.sumVacationDays }} Tage</p>
|
||||
<p>Krankheitsausgleich: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b> / {{ workingTimeInfo.summary.sumSickDays }} Tage</p>
|
||||
<p>Soll-Stunden: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
|
||||
|
||||
<p class="col-span-2">
|
||||
Inoffizielles Saldo: <b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
|
||||
</p>
|
||||
<p class="col-span-2">
|
||||
Saldo: <b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UDashboardPanel>
|
||||
<UTable
|
||||
v-if="workingTimeInfo"
|
||||
:rows="workingTimeInfo.spans"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Anwesenheiten' }"
|
||||
:columns="[
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'startedAt', label: 'Start' },
|
||||
{ key: 'endedAt', label: 'Ende' },
|
||||
{ key: 'duration', label: 'Dauer' },
|
||||
{ key: 'type', label: 'Typ' }
|
||||
]"
|
||||
@select="(row) => router.push(`/workingtimes/edit/${row.sourceEventIds[0]}`)"
|
||||
>
|
||||
<template #status-data="{row}">
|
||||
<span v-if="row.status === 'approved'" class="text-primary-500">Genehmigt</span>
|
||||
<span v-else-if="row.status === 'submitted'" class="text-cyan-500">Eingereicht</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 #startedAt-data="{ row }">
|
||||
{{ $dayjs(row.startedAt).format('HH:mm DD.MM.YY') }} Uhr
|
||||
</template>
|
||||
|
||||
<template #endedAt-data="{ row }">
|
||||
{{ $dayjs(row.endedAt).format('HH:mm DD.MM.YY') }} Uhr
|
||||
</template>
|
||||
|
||||
<template #duration-data="{ row }">
|
||||
{{ formatSpanDuration(row.startedAt, row.endedAt) }}
|
||||
</template>
|
||||
|
||||
<template #type-data="{ row }">
|
||||
{{ row.type.charAt(0).toUpperCase() + row.type.slice(1).replace('_', ' ') }}
|
||||
</template>
|
||||
</UTable>
|
||||
</UDashboardPanel>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.label === 'Bericht'">
|
||||
<PDFViewer
|
||||
v-if="showDocument"
|
||||
:uri="uri"
|
||||
location="show_time_evaluation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<UDashboardNavbar title="Auswertung">
|
||||
<template #toggle><div></div></template>
|
||||
<template #left>
|
||||
<UButton
|
||||
icon="i-heroicons-chevron-left"
|
||||
variant="ghost"
|
||||
@click="router.push('/staff/time')"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<div class="p-4 space-y-4 border-b bg-white dark:bg-gray-900">
|
||||
<USelectMenu
|
||||
v-model="selectedPresetRange"
|
||||
:options="[
|
||||
'Dieser Monat bis heute',
|
||||
'Diese Woche',
|
||||
'Dieser Monat',
|
||||
'Dieses Jahr',
|
||||
'Letzte Woche',
|
||||
'Letzter Monat',
|
||||
'Letztes Jahr'
|
||||
]"
|
||||
@change="changeRange"
|
||||
placeholder="Zeitraum wählen"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">Start</p>
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UButton
|
||||
icon="i-heroicons-calendar"
|
||||
class="w-full"
|
||||
:label="$dayjs(selectedStartDay).format('DD.MM.YYYY')"
|
||||
/>
|
||||
<template #panel>
|
||||
<LazyDatePicker v-model="selectedStartDay" @close="loadWorkingTimeInfo" />
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">Ende</p>
|
||||
<UPopover :popper="{ placement: 'bottom-start' }">
|
||||
<UButton
|
||||
icon="i-heroicons-calendar"
|
||||
class="w-full"
|
||||
:label="$dayjs(selectedEndDay).format('DD.MM.YYYY')"
|
||||
/>
|
||||
<template #panel>
|
||||
<LazyDatePicker v-model="selectedEndDay" @close="loadWorkingTimeInfo" />
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UTabs
|
||||
:items="[{ label: 'Information' }, { label: 'Bericht' }]"
|
||||
v-model="openTab"
|
||||
@change="onTabChange"
|
||||
class="mt-3 mx-3"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
|
||||
<div v-if="item.label === 'Information'" class="space-y-4">
|
||||
|
||||
<UCard v-if="workingTimeInfo && workingTimeInfo.summary" class="mt-3">
|
||||
<template #header>
|
||||
<h3 class="text-base font-semibold">Zusammenfassung</h3>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<p>Eingereicht: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSubmitted) }}</b></p>
|
||||
<p>Genehmigt: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesApproved) }}</b></p>
|
||||
|
||||
<p>
|
||||
Feiertagsausgleich:
|
||||
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesRecreationDays) }}</b>
|
||||
/ {{ workingTimeInfo.summary.sumRecreationDays }} Tage
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Urlaubs-/Berufsschule:
|
||||
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesVacationDays) }}</b>
|
||||
/ {{ workingTimeInfo.summary.sumVacationDays }} Tage
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Krankheitsausgleich:
|
||||
<b>{{ formatMinutesToHHMM(workingTimeInfo.summary.sumWorkingMinutesSickDays) }}</b>
|
||||
/ {{ workingTimeInfo.summary.sumSickDays }} Tage
|
||||
</p>
|
||||
|
||||
<p>Soll: <b>{{ formatMinutesToHHMM(workingTimeInfo.summary.timeSpanWorkingMinutes) }}</b></p>
|
||||
|
||||
<p>
|
||||
Inoffizielles Saldo:
|
||||
<b>{{ (workingTimeInfo.summary.saldoSubmitted >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoSubmitted)) }}</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Saldo:
|
||||
<b>{{ (workingTimeInfo.summary.saldoApproved >= 0 ? '+' : '-') + formatMinutesToHHMM(Math.abs(workingTimeInfo.summary.saldoApproved)) }}</b>
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.label === 'Bericht'">
|
||||
<UButton
|
||||
v-if="uri && !fileSaved"
|
||||
icon="i-mdi-content-save"
|
||||
color="primary"
|
||||
class="w-full mb-3"
|
||||
@click="saveFile"
|
||||
>
|
||||
Bericht speichern
|
||||
</UButton>
|
||||
|
||||
<PDFViewer
|
||||
v-if="showDocument"
|
||||
:uri="uri"
|
||||
location="show_time_evaluation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</template>
|
||||
|
||||
|
||||
</template>
|
||||
473
frontend/pages/staff/time/index.vue
Normal file
473
frontend/pages/staff/time/index.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<script setup>
|
||||
import { useStaffTime } from '~/composables/useStaffTime'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import FloatingActionButton from "~/components/mobile/FloatingActionButton.vue";
|
||||
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
})
|
||||
|
||||
const { list, start, stop, submit, approve, reject } = useStaffTime()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { $dayjs } = useNuxtApp()
|
||||
|
||||
// MOBILE DETECTION
|
||||
const platformIsNative = useCapacitor().getIsNative()
|
||||
|
||||
// STATE
|
||||
const loading = ref(false)
|
||||
const view = ref('list') // 'list' | 'timeline'
|
||||
|
||||
// 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 selectedUser = ref(auth.user.id)
|
||||
const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all'))
|
||||
|
||||
// DATA
|
||||
const entries = ref([])
|
||||
const active = computed(() => entries.value.find(e => !e.stopped_at))
|
||||
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 = {
|
||||
work: "Arbeitszeit",
|
||||
vacation: "Urlaub",
|
||||
sick: "Krankheit",
|
||||
holiday: "Feiertag",
|
||||
other: "Sonstiges"
|
||||
}
|
||||
|
||||
const typeColor = {
|
||||
work: "gray",
|
||||
vacation: "yellow",
|
||||
sick: "rose",
|
||||
holiday: "blue",
|
||||
other: "gray"
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
async function loadUsers() {
|
||||
if (!canViewAll.value) return
|
||||
const res = (await useNuxtApp().$api("/api/tenant/profiles")).data
|
||||
users.value = res
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!selectedUser.value) return
|
||||
entries.value = await list({ user_id: selectedUser.value })
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!isViewingSelf.value) return
|
||||
loading.value = true
|
||||
await start("Arbeitszeit gestartet")
|
||||
await load()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!active.value) return
|
||||
loading.value = true
|
||||
await stop(active.value.id)
|
||||
await load()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handleEdit(entry) {
|
||||
entryToEdit.value = entry
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit(entry) {
|
||||
loading.value = true
|
||||
await submit(entry)
|
||||
await load()
|
||||
loading.value = false
|
||||
toast.add({ title: 'Zeit eingereicht', color: 'green' })
|
||||
}
|
||||
|
||||
async function handleApprove(entry) {
|
||||
loading.value = true
|
||||
await approve(entry)
|
||||
await load()
|
||||
loading.value = false
|
||||
toast.add({ title: 'Zeit genehmigt', color: 'green' })
|
||||
}
|
||||
|
||||
function openRejectModal(entry) {
|
||||
entryToReject.value = entry
|
||||
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 () => {
|
||||
await loadUsers()
|
||||
await load()
|
||||
setPageLayout(platformIsNative ? 'mobile' : 'default')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="!platformIsNative">
|
||||
<UDashboardNavbar title="Zeiterfassung" :badge="entries.length" />
|
||||
|
||||
<UDashboardToolbar>
|
||||
<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">
|
||||
<USelectMenu
|
||||
v-model="selectedUser"
|
||||
:options="users.map(u => ({ label: u.full_name || u.email, value: u.user_id }))"
|
||||
placeholder="Benutzer auswählen"
|
||||
value-attribute="value"
|
||||
option-attribute="label"
|
||||
class="min-w-[220px]"
|
||||
:clearable="false"
|
||||
/>
|
||||
<UTooltip text="Anwesenheiten auswerten">
|
||||
<UButton
|
||||
:disabled="!selectedUser"
|
||||
color="gray"
|
||||
icon="i-heroicons-chart-bar"
|
||||
variant="ghost"
|
||||
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
</UDashboardToolbar>
|
||||
|
||||
<UDashboardPanelContent class="p-0 sm:p-4">
|
||||
|
||||
<UCard v-if="view === 'list'" :ui="{ body: { padding: 'p-0 sm:p-0' } }">
|
||||
<UTable
|
||||
:rows="entries"
|
||||
:columns="[
|
||||
{ key: 'actions', label: 'Aktionen', class: 'w-32' },
|
||||
{ key: 'state', label: 'Status' },
|
||||
{ key: 'started_at', label: 'Start' },
|
||||
{ key: 'stopped_at', label: 'Ende' },
|
||||
{ key: 'duration_minutes', label: 'Dauer' },
|
||||
{ key: 'type', label: 'Typ' },
|
||||
{ key: 'description', label: 'Beschreibung' },
|
||||
]"
|
||||
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Zeiten anzuzeigen' }"
|
||||
>
|
||||
<template #state-data="{ row }">
|
||||
<UBadge v-if="row.state === 'approved'" color="green" variant="subtle">Genehmigt</UBadge>
|
||||
<UBadge v-else-if="row.state === 'submitted'" color="cyan" variant="subtle">Eingereicht</UBadge>
|
||||
<UBadge v-else-if="row.state === 'rejected'" color="red" variant="subtle">Abgelehnt</UBadge>
|
||||
<UBadge v-else color="gray" variant="subtle">Entwurf</UBadge>
|
||||
</template>
|
||||
<template #type-data="{ row }">
|
||||
<UBadge :color="typeColor[row.type] || 'gray'" variant="soft">{{ typeLabel[row.type] || row.type }}</UBadge>
|
||||
</template>
|
||||
<template #started_at-data="{ row }">
|
||||
<span v-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}</span>
|
||||
<span v-else>{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}</span>
|
||||
</template>
|
||||
<template #stopped_at-data="{ row }">
|
||||
<span v-if="!row.stopped_at" class="text-primary-500 font-medium animate-pulse">läuft...</span>
|
||||
<span v-else-if="['vacation','sick'].includes(row.type)">{{ 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 #duration_minutes-data="{ row }">
|
||||
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
|
||||
</template>
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<UTooltip text="Einreichen" v-if="(row.state === 'draft' || row.state === 'factual') && row.stopped_at">
|
||||
<UButton size="xs" color="cyan" variant="ghost" icon="i-heroicons-paper-airplane" @click="handleSubmit(row)" :loading="loading" />
|
||||
</UTooltip>
|
||||
<UTooltip text="Genehmigen" v-if="row.state === 'submitted' && canViewAll">
|
||||
<UButton size="xs" color="green" variant="ghost" icon="i-heroicons-check" @click="handleApprove(row)" :loading="loading" />
|
||||
</UTooltip>
|
||||
<UTooltip text="Ablehnen" v-if="(row.state === 'submitted' || row.state === 'approved') && canViewAll">
|
||||
<UButton size="xs" color="red" variant="ghost" icon="i-heroicons-x-mark" @click="openRejectModal(row)" :loading="loading" />
|
||||
</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 #description-data="{ row }">
|
||||
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span>
|
||||
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
|
||||
<span v-else>{{row.description}}</span>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<UDashboardNavbar title="Zeiterfassung" />
|
||||
<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">
|
||||
<UCard class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-500 text-sm">Aktive Zeit</p>
|
||||
<p v-if="active" class="text-primary-600 font-semibold animate-pulse">
|
||||
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }}
|
||||
</p>
|
||||
<p v-else class="text-gray-600">Keine aktive Zeit</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="isViewingSelf">
|
||||
<UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" @click="handleStop" />
|
||||
<UButton v-else color="green" icon="i-heroicons-play" :loading="loading" @click="handleStart" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
<div class="px-3 mt-3">
|
||||
<UButton color="gray" icon="i-heroicons-chart-bar" label="Auswertung" class="w-full" variant="soft" @click="router.push(`/staff/time/${selectedUser}/evaluate`)" />
|
||||
</div>
|
||||
<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)">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="font-semibold flex items-center gap-2">
|
||||
<span class="truncate max-w-[150px]">{{ row.description || 'Keine Beschreibung' }}</span>
|
||||
<UBadge :color="typeColor[row.type]" class="text-xs">{{ typeLabel[row.type] }}</UBadge>
|
||||
</div>
|
||||
<UBadge v-if="row.state === 'approved'" color="green">Genehmigt</UBadge>
|
||||
<UBadge v-else-if="row.state === 'submitted'" color="cyan">Eingereicht</UBadge>
|
||||
<UBadge v-else-if="row.state === 'rejected'" color="red">Abgelehnt</UBadge>
|
||||
<UBadge v-else color="gray">Entwurf</UBadge>
|
||||
</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">Ende: <span v-if="row.stopped_at">{{ useNuxtApp().$dayjs(row.stopped_at).format('DD.MM.YY HH:mm') }}</span></p>
|
||||
<div class="flex gap-2 mt-3 justify-end">
|
||||
<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" />
|
||||
</div>
|
||||
</UCard>
|
||||
</UDashboardPanelContent>
|
||||
<FloatingActionButton icon="i-heroicons-plus" class="!fixed bottom-6 right-6 z-50" color="primary" @click="() => { entryToEdit = null; showEditModal = true }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<StaffTimeEntryModal
|
||||
v-model="showEditModal"
|
||||
:entry="entryToEdit"
|
||||
@saved="load"
|
||||
: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>
|
||||
Reference in New Issue
Block a user