Merge branch 'beta' into 'main'

Beta

See merge request fedeo/software!55
This commit is contained in:
2025-12-27 13:06:20 +00:00
26 changed files with 1064 additions and 833 deletions

View File

@@ -1,4 +1,6 @@
<script setup > <script setup>
// Falls useDropZone nicht auto-importiert wird:
// import { useDropZone } from '@vueuse/core'
const props = defineProps({ const props = defineProps({
fileData: { fileData: {
@@ -12,11 +14,35 @@ const props = defineProps({
const emit = defineEmits(["uploadFinished"]) const emit = defineEmits(["uploadFinished"])
const modal = useModal() const modal = useModal()
const profileStore = useProfileStore() // const profileStore = useProfileStore() // Wird im Snippet nicht genutzt, aber ich lasse es drin
const uploadInProgress = ref(false) const uploadInProgress = ref(false)
const availableFiletypes = ref([]) const availableFiletypes = ref([])
// 1. State für die Dateien und die Dropzone Referenz
const selectedFiles = ref([])
const dropZoneRef = ref(null)
// 2. Setup der Dropzone
const onDrop = (files) => {
// Wenn Dateien gedroppt werden, speichern wir sie
// files ist hier meist ein Array, wir stellen sicher, dass es passt
selectedFiles.value = files || []
}
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop,
// Verhindert, dass der Browser das Bild einfach öffnet
preventDefaultForDrop: true,
})
// 3. Handler für den klassischen Datei-Input Klick
const onFileInputChange = (e) => {
if (e.target.files) {
selectedFiles.value = Array.from(e.target.files)
}
}
const setup = async () => { const setup = async () => {
availableFiletypes.value = await useEntities("filetags").select() availableFiletypes.value = await useEntities("filetags").select()
} }
@@ -24,81 +50,112 @@ const setup = async () => {
setup() setup()
const uploadFiles = async () => { const uploadFiles = async () => {
// Validierung: Keine Dateien ausgewählt
if (!selectedFiles.value || selectedFiles.value.length === 0) {
alert("Bitte wählen Sie zuerst Dateien aus.") // Oder eine schönere Toast Notification
return
}
uploadInProgress.value = true; uploadInProgress.value = true;
let fileData = props.fileData let fileData = props.fileData
delete fileData.typeEnabled delete fileData.typeEnabled
// 4. Hier nutzen wir nun selectedFiles.value statt document.getElementById
await useFiles().uploadFiles(fileData, selectedFiles.value, [], true)
await useFiles().uploadFiles(fileData, document.getElementById("fileUploadInput").files,[],true)
uploadInProgress.value = false; uploadInProgress.value = false;
emit("uploadFinished") emit("uploadFinished")
modal.close() modal.close()
} }
// Helper Funktion um Dateinamen anzuzeigen (da das Input Feld leer bleibt beim Droppen)
const fileNames = computed(() => {
if (!selectedFiles.value.length) return ''
return selectedFiles.value.map(f => f.name).join(', ')
})
</script> </script>
<template> <template>
<UModal> <UModal>
<UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"> <div ref="dropZoneRef" class="relative h-full flex flex-col">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Datei hochladen
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
:disabled="uploadInProgress"
/>
</div>
</template>
<UFormGroup <div
label="Datei:" v-if="isOverDropZone"
class="absolute inset-0 z-50 flex items-center justify-center bg-primary-500/10 border-2 border-primary-500 border-dashed rounded-lg backdrop-blur-sm transition-all"
> >
<UInput <span class="text-xl font-bold text-primary-600 bg-white/80 px-4 py-2 rounded shadow-sm">
type="file" Dateien hier ablegen
id="fileUploadInput" </span>
multiple </div>
accept="image/jpeg, image/png, image/gif, application/pdf"
/> <UCard :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
</UFormGroup> <template #header>
<UFormGroup <div class="flex items-center justify-between">
label="Typ:" <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
class="mt-3" Datei hochladen
> </h3>
<USelectMenu <UButton
option-attribute="name" color="gray"
value-attribute="id" variant="ghost"
searchable icon="i-heroicons-x-mark-20-solid"
searchable-placeholder="Suchen..." class="-my-1"
:options="availableFiletypes" @click="modal.close()"
v-model="props.fileData.type" :disabled="uploadInProgress"
:disabled="!props.fileData.typeEnabled" />
</div>
</template>
<UFormGroup
label="Datei:"
:help="selectedFiles.length > 0 ? `${selectedFiles.length} Datei(en) ausgewählt` : 'Ziehen Sie Dateien hierher oder klicken Sie'"
> >
<template #label> <UInput
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span> v-if="selectedFiles.length === 0"
<span v-else>Keine Typ ausgewählt</span> type="file"
</template> id="fileUploadInput"
</USelectMenu> multiple
</UFormGroup> accept="image/jpeg, image/png, image/gif, application/pdf"
@change="onFileInputChange"
/>
<template #footer> <div v-if="selectedFiles.length > 0" class="mt-2 text-sm text-gray-500">
<UButton Ausgewählt: <span class="font-medium text-gray-700 dark:text-gray-300">{{ fileNames }}</span>
@click="uploadFiles" </div>
:loading="uploadInProgress" </UFormGroup>
:disabled="uploadInProgress"
>Hochladen</UButton> <UFormGroup
</template> label="Typ:"
</UCard> class="mt-3"
>
<USelectMenu
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Suchen..."
:options="availableFiletypes"
v-model="props.fileData.type"
:disabled="!props.fileData.typeEnabled"
>
<template #label>
<span v-if="availableFiletypes.find(x => x.id === props.fileData.type)">{{availableFiletypes.find(x => x.id === props.fileData.type).name}}</span>
<span v-else>Kein Typ ausgewählt</span>
</template>
</USelectMenu>
</UFormGroup>
<template #footer>
<UButton
@click="uploadFiles"
:loading="uploadInProgress"
:disabled="uploadInProgress || selectedFiles.length === 0"
>Hochladen</UButton>
</template>
</UCard>
</div>
</UModal> </UModal>
</template> </template>
<style scoped> <style scoped>
/* Optional: Animationen für das Overlay */
</style> </style>

View File

@@ -107,7 +107,6 @@ const getAvailableQueryStringData = (keys) => {
returnString += `${key}=${value}` returnString += `${key}=${value}`
} else { } else {
returnString += `&${key}=${value}` returnString += `&${key}=${value}`
} }
} }

View File

@@ -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: "", } else {
}); // CREATE (Standardwerte: Heute)
const now = $dayjs()
state.start_date = now.format('YYYY-MM-DD')
state.start_time = now.format('HH:mm')
state.end_date = ''
state.end_time = ''
state.type = 'work'
state.description = ''
}
}, { immediate: true })
// 📡 ENTRY —> LOCAL async function onSubmit(event: FormSubmitEvent<any>) {
watch( loading.value = true
() => 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 {
Object.assign(local, {
id: "",
user_id: props.defaultUserId, // 👈 Neuer Eintrag → aktueller Nutzer
description: "",
type: "work",
started_at: dayjs().format("YYYY-MM-DDTHH:mm"),
stopped_at: dayjs().add(1, "hour").format("YYYY-MM-DDTHH:mm"),
vacation_reason: "",
sick_reason: "",
});
}
},
{ immediate: true }
);
const loading = ref(false);
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;
} else {
payload.started_at = dayjs(local.started_at).toISOString();
payload.stopped_at = local.stopped_at
? dayjs(local.stopped_at).toISOString()
: null;
payload.description = local.description;
if (local.type === "sick") { if ($dayjs(endIso).isBefore($dayjs(startIso))) {
payload.sick_reason = local.sick_reason; throw new Error("Endzeitpunkt muss nach dem Startzeitpunkt liegen.")
} }
} }
if (local.id) { if (props.entry) {
await update(local.id, payload); // 🟢 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 {
await create(payload); // 🟢 CREATE (Neu Erstellen)
// 💡 HIER WAR DER FEHLER: Wir nutzen jetzt createEntry mit den Daten aus dem Formular
await createEntry({
start: startIso, // Die eingegebene Startzeit
end: endIso, // Die eingegebene Endzeit (oder null)
type: state.type,
description: state.description
})
toast.add({ title: 'Zeit manuell erfasst', color: 'green' })
} }
emit("saved"); emit('saved')
show.value = false; isOpen.value = false
} catch (error: any) {
toast.add({ title: 'Fehler', description: error.message, color: 'red' })
} 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>

View File

@@ -9,6 +9,7 @@ const props = defineProps({
</script> </script>
<template> <template>
<span v-if="props.row.infoData.streetNumber">{{props.row.infoData.streetNumber}},</span>
<span v-if="props.row.infoData.street">{{props.row.infoData.street}},</span> <span v-if="props.row.infoData.street">{{props.row.infoData.street}},</span>
<span v-if="props.row.infoData.special">{{props.row.infoData.special}},</span> <span v-if="props.row.infoData.special">{{props.row.infoData.special}},</span>
<span v-if="props.row.infoData.zip">{{props.row.infoData.zip}},</span> <span v-if="props.row.infoData.zip">{{props.row.infoData.zip}},</span>

View File

@@ -9,5 +9,5 @@ const props = defineProps({
</script> </script>
<template> <template>
<span>{{props.row.purchasePrice ? useCurrency(props.row.purchasePrice) : ''}}</span> <span>{{props.row.purchase_price ? useCurrency(props.row.purchase_price) : ''}}</span>
</template> </template>

View File

@@ -9,5 +9,5 @@ const props = defineProps({
</script> </script>
<template> <template>
<span>{{props.row.sellingPrice ? useCurrency(props.row.sellingPrice) : ''}}</span> <span>{{props.row.selling_price ? useCurrency(props.row.selling_price) : ''}}</span>
</template> </template>

View File

@@ -60,7 +60,7 @@ const mappings = ref({
}) })
const startImport = () => { const startImport = () => {
router.push(`/createDocument/edit/?linkedDocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`) router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
modal.close() modal.close()
} }
</script> </script>

View File

@@ -22,7 +22,7 @@ const setupPage = async () => {
finalizedDocuments = finalizedDocuments.filter(i => i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, documents).toFixed(2)) finalizedDocuments = finalizedDocuments.filter(i => i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== useSum().getCreatedDocumentSum(i, documents).toFixed(2))
finalizedDocuments = finalizedDocuments.filter(x => (x.type === 'invoices' || x.type === 'advanceInvoices') && x.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === x.id)) finalizedDocuments = finalizedDocuments.filter(x => (x.type === 'invoices' || x.type === 'advanceInvoices') && x.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === x.id))

View File

@@ -53,7 +53,7 @@ const setRowData = (row) => {
let product = products.value.find(i => i.id === row.product) let product = products.value.find(i => i.id === row.product)
row.unit = product.unit row.unit = product.unit
row.price = product.sellingPrice row.price = product.selling_price
calculateTotalMaterialPrice() calculateTotalMaterialPrice()
} }

View File

@@ -134,11 +134,11 @@ export const useEntities = (
const selectSingle = async ( const selectSingle = async (
idToEq: string | number, idToEq: string | number,
select: string = "*", select: string = "*",
withInformation: boolean = false withInformation: boolean = true
) => { ) => {
if (!idToEq) return null if (!idToEq) return null
const res = await useNuxtApp().$api(withInformation ? `/api/resource/${relation}/${idToEq}` : `/api/resource/${relation}/${idToEq}`, { const res = await useNuxtApp().$api(withInformation ? `/api/resource/${relation}/${idToEq}` : `/api/resource/${relation}/${idToEq}/true`, {
method: "GET", method: "GET",
params: { select } params: { select }
}) })

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,119 @@
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()
// ... (list Funktion bleibt gleich) ...
const list = async (filter?: { user_id?: string, from?: string, to?: string }) => {
async function list(params?: { user_id?: string }) { // ... (Code wie zuvor)
const query = new URLSearchParams() const from = filter?.from || $dayjs().startOf('month').format('YYYY-MM-DD')
if (params?.user_id) query.append("user_id", params.user_id) const to = filter?.to || $dayjs().endOf('month').format('YYYY-MM-DD')
const targetUserId = filter?.user_id || auth.user.id
return await $api(`/api/staff/time${query.toString() ? `?${query}` : ''}`, { method: 'GET' }) const params = new URLSearchParams({ from, to, targetUserId })
try {
const spans = await $api(`/api/staff/time/spans?${params.toString()}`)
return (spans || []).map((span: any) => {
const start = $dayjs(span.startedAt)
const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs()
return {
id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null,
eventIds: span.sourceEventIds || [],
state: span.status,
started_at: span.startedAt,
stopped_at: span.endedAt,
duration_minutes: end.diff(start, 'minute'),
user_id: targetUserId,
type: span.type,
description: span.payload?.description || ''
}
}).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf())
} catch (error) {
console.error("Fehler beim Laden:", error)
return []
}
} }
async function start(description?: string) { /**
return await $api<StaffTimeEntry>('/api/staff/time', { * Startet "jetzt" (Live-Modus).
* Kann optional eine Zeit empfangen (für manuelle Korrekturen),
* aber wir nutzen dafür besser die createEntry Funktion unten.
*/
const start = async (description = "Arbeitszeit", time?: string) => {
await $api('/api/staff/time/event', {
method: 'POST', method: 'POST',
body: { body: {
started_at: new Date().toISOString(), eventtype: 'work_start',
type: 'work', eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren
description, payload: { description }
}, }
}) })
} }
async function stop(id: string) { const stop = async () => {
return await $api<StaffTimeEntry>(`/api/staff/time/${id}/stop`, { await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString() } })
method: 'PUT',
body: { stopped_at: new Date().toISOString() },
})
} }
async function submit(id: string) { const submit = async (entry: any) => {
return await $api<StaffTimeEntry>(`/api/staff/time/${id}`, { const ids = entry.eventIds || (entry.id ? [entry.id] : [entry]);
method: 'PUT', if (!ids || ids.length === 0) return
body: { state: 'submitted' }, await $api('/api/staff/time/submit', { method: 'POST', body: { eventIds: ids } })
})
} }
async function approve(id: string) { const approve = async (entry: any) => {
const auth = useAuthStore() if (!entry?.user_id) return
const now = useNuxtApp().$dayjs().toISOString() const ids = entry.eventIds || [entry.id];
await $api('/api/staff/time/approve', { method: 'POST', body: { eventIds: ids, employeeUserId: entry.user_id } })
}
return await $api(`/api/staff/time/${id}`, { const reject = async (entry: any, reason = "Abgelehnt") => {
method: 'PUT', 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: { body: {
state: 'approved', originalEventIds: entry.eventIds,
//@ts-ignore newStart: newData.start,
approved_by: auth.user.id, newEnd: newData.end,
approved_at: now, newType: newData.type,
}, description: newData.description,
reason: "Manuelle Bearbeitung"
}
}) })
} }
async function get(id: string) { // 🆕 NEU: Manuellen Eintrag erstellen (Vergangenheit oder Zeitraum)
return await $api<StaffTimeEntry>(`/api/staff/time/${id}`, { method: 'GET' }) 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', {
method: 'POST',
body: {
eventtype: `${data.type}_start`,
eventtime: data.start,
payload: { description: data.description }
}
})
// 2. End Event senden (falls vorhanden)
if (data.end) {
await $api('/api/staff/time/event', {
method: 'POST',
body: {
eventtype: `${data.type}_end`,
eventtime: data.end
}
})
}
} }
async function create(data: Record<string, any>) { return { list, start, stop, submit, approve, reject, update, createEntry }
return await $api('/api/staff/time', { method: 'POST', body: data })
}
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

@@ -7,7 +7,7 @@ export default defineNuxtConfig({
} }
}, },
modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', 'nuxt-tiptap-editor', '@nuxtjs/leaflet'], modules: ['@pinia/nuxt', '@nuxt/ui', '@nuxtjs/supabase', "nuxt-editorjs", '@nuxtjs/fontaine', 'nuxt-viewport', 'nuxt-tiptap-editor', '@nuxtjs/leaflet', '@vueuse/nuxt'],
ssr: false, ssr: false,

View File

@@ -13,6 +13,8 @@
"@capacitor/cli": "^7.0.0", "@capacitor/cli": "^7.0.0",
"@nuxtjs/leaflet": "^1.2.3", "@nuxtjs/leaflet": "^1.2.3",
"@nuxtjs/supabase": "^1.1.4", "@nuxtjs/supabase": "^1.1.4",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "^14.1.0",
"nuxt": "^3.14.1592", "nuxt": "^3.14.1592",
"nuxt-tiptap-editor": "^1.2.0", "nuxt-tiptap-editor": "^1.2.0",
"vue": "^3.5.13", "vue": "^3.5.13",
@@ -35,6 +37,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 +66,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 +85,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"
} }
} }

View File

@@ -58,7 +58,7 @@ const clearSearchString = () => {
searchString.value = '' searchString.value = ''
} }
const filterAccount = ref(bankaccounts || []) const filterAccount = ref(bankaccounts.value || [])
const displayCurrency = (value, currency = "€") => { const displayCurrency = (value, currency = "€") => {
return `${Number(value).toFixed(2).replace(".",",")} ${currency}` return `${Number(value).toFixed(2).replace(".",",")} ${currency}`
@@ -150,7 +150,7 @@ setupPage()
</USelectMenu> </USelectMenu>
</template> </template>
<template #right> <template #right>
<USelectMenu <!-- <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns" :options="templateColumns"
@@ -162,7 +162,7 @@ setupPage()
<template #label> <template #label>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>-->
<USelectMenu <USelectMenu
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
multiple multiple

View File

@@ -12,7 +12,7 @@ defineShortcuts({
}) })
const dataStore = useDataStore() const dataStore = useDataStore()
const profileStore = useProfileStore() const tempStore = useTempStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const mode = ref(route.params.mode || "show") const mode = ref(route.params.mode || "show")
@@ -29,7 +29,7 @@ const customers = ref([])
const vendors = ref([]) const vendors = ref([])
const createddocuments = ref([]) const createddocuments = ref([])
const incominginvoices = ref([])
const accounts = ref([]) const accounts = ref([])
const ownaccounts = ref([]) const ownaccounts = ref([])
@@ -45,7 +45,7 @@ const setup = async () => {
createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)")) createddocuments.value = (await useEntities("createddocuments").select("*, statementallocations(*), customer(id,name)"))
const documents = createddocuments.value.filter(i => i.type === "invoices" ||i.type === "advanceInvoices") const documents = createddocuments.value.filter(i => i.type === "invoices" ||i.type === "advanceInvoices")
const incominginvoices = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(id,name)")).filter(i => i.state === "Gebucht") incominginvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(id,name)")).filter(i => i.state === "Gebucht")
accounts.value = (await useEntities("accounts").selectSpecial("*","number",true)) accounts.value = (await useEntities("accounts").selectSpecial("*","number",true))
ownaccounts.value = (await useEntities("ownaccounts").select()) ownaccounts.value = (await useEntities("ownaccounts").select())
@@ -66,7 +66,7 @@ const setup = async () => {
console.log(openDocuments.value) console.log(openDocuments.value)
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id)) allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id)) allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
console.log(allocatedDocuments.value) console.log(allocatedDocuments.value)
console.log(allocatedIncomingInvoices.value) console.log(allocatedIncomingInvoices.value)
openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i,false)) openIncomingInvoices.value = (await useEntities("incominginvoices").select("*, statementallocations(*), vendor(*)")).filter(i => !i.archived && i.statementallocations.reduce((n,{amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i,false))
@@ -177,7 +177,14 @@ const removeAllocation = async (allocationId) => {
await setup() await setup()
} }
const searchString = ref("") const searchString = ref(tempStore.searchStrings["bankstatementsedit"] ||'')
const clearSearchString = () => {
searchString.value = ''
tempStore.clearSearchString("bankstatementsedit")
}
const filteredDocuments = computed(() => { const filteredDocuments = computed(() => {
@@ -509,11 +516,11 @@ const archiveStatement = async () => {
</UCard> </UCard>
<UCard <UCard
class="mt-5" class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.ii_id)" v-for="item in itemInfo.statementallocations.filter(i => i.incominginvoice)"
> >
<template #header> <template #header>
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<span>{{item.ii_id.reference}} - {{vendors.find(i => i.id === item.ii_id.vendor).name}}</span> <span> {{incominginvoices.find(i => i.id === item.incominginvoice).reference}} - {{incominginvoices.find(i => i.id === item.incominginvoice).vendor?.name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span> <span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div> </div>
</template> </template>
@@ -529,11 +536,11 @@ const archiveStatement = async () => {
</UCard> </UCard>
<UCard <UCard
class="mt-5" class="mt-5"
v-for="item in itemInfo.statementallocations.filter(i => i.cd_id)" v-for="item in itemInfo.statementallocations.filter(i => i.createddocument)"
> >
<template #header> <template #header>
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<span v-if="customers.find(i => i.id === item.cd_id.customer)">{{item.cd_id.documentNumber}} - {{customers.find(i => i.id === item.cd_id.customer).name}}</span> <span v-if="customers.find(i => i.id === createddocuments.find(i => i.id === item.createddocument).customer?.id)">{{createddocuments.find(i => i.id === item.createddocument).documentNumber}} - {{createddocuments.find(i => i.id === item.createddocument).customer?.name}}</span>
<span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span> <span class="font-semibold text-nowrap">{{displayCurrency(item.amount)}}</span>
</div> </div>
</template> </template>
@@ -546,7 +553,15 @@ const archiveStatement = async () => {
class="mr-3 mt-3" class="mr-3 mt-3"
@click="removeAllocation(item.id)" @click="removeAllocation(item.id)"
/> />
<UButton
icon="i-heroicons-eye"
variant="outline"
color="primary"
class="mr-3 mt-3"
@click="navigateTo(`/createDocument/show/${item.createddocument}`)"
/>
</UCard> </UCard>
</div> </div>
</div> </div>
@@ -780,6 +795,7 @@ const archiveStatement = async () => {
placeholder="Suche..." placeholder="Suche..."
class="hidden lg:block w-full mr-1" class="hidden lg:block w-full mr-1"
@keydown.esc="$event.target.blur()" @keydown.esc="$event.target.blur()"
@change="tempStore.modifySearchString('bankstatementsedit',searchString)"
> >
<template #trailing> <template #trailing>
<UKbd value="/" /> <UKbd value="/" />
@@ -789,7 +805,7 @@ const archiveStatement = async () => {
variant="outline" variant="outline"
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
color="rose" color="rose"
@click="searchString = ''" @click="clearSearchString"
/> />
</InputGroup> </InputGroup>
</div> </div>
@@ -810,8 +826,8 @@ const archiveStatement = async () => {
icon="i-heroicons-check" icon="i-heroicons-check"
variant="outline" variant="outline"
class="mr-3" class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.cd_id === document.id)" v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id)"
@click="saveAllocation({cd_id: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})" @click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/> />
<UButton <UButton
@@ -835,8 +851,8 @@ const archiveStatement = async () => {
icon="i-heroicons-check" icon="i-heroicons-check"
variant="outline" variant="outline"
class="mr-3" class="mr-3"
v-if="!itemInfo.statementallocations.find(i => i.ii_id === item.id)" v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id)"
@click="saveAllocation({ii_id: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})" @click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/> />
<UButton <UButton
variant="outline" variant="outline"

View File

@@ -109,7 +109,7 @@ const setupPage = async () => {
if (route.params) { if (route.params) {
if (route.params.id) { if (route.params.id) {
console.log(route.params) console.log(route.params)
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false)
await setContactPersonData() await setContactPersonData()
checkCompatibilityWithInputPrice() checkCompatibilityWithInputPrice()
} }
@@ -141,9 +141,9 @@ const setupPage = async () => {
//TODO: Implement Checking for Same Customer, Contact and Project //TODO: Implement Checking for Same Customer, Contact and Project
itemInfo.value.customer = linkedDocuments[0].customer itemInfo.value.customer = linkedDocuments[0].customer ? linkedDocuments[0].customer.id : null
itemInfo.value.project = linkedDocuments[0].project itemInfo.value.project = linkedDocuments[0].project ? linkedDocuments[0].project.id : null
itemInfo.value.contact = linkedDocuments[0].contact itemInfo.value.contact = linkedDocuments[0].contact ? linkedDocuments[0].contact.id : null
setCustomerData() setCustomerData()
@@ -276,9 +276,9 @@ const setupPage = async () => {
} }
if (route.query.linkedDocument) { if (route.query.createddocument) {
itemInfo.value.linkedDocument = route.query.linkedDocument itemInfo.value.createddocument = route.query.createddocument
let linkedDocument = await useEntities("createddocuments").selectSingle(itemInfo.value.linkedDocument) let linkedDocument = await useEntities("createddocuments").selectSingle(itemInfo.value.createddocument,'',false)
if (route.query.optionsToImport) { if (route.query.optionsToImport) {
//Import only true //Import only true
@@ -1428,7 +1428,8 @@ const closeDocument = async () => {
fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id
const tags = await useEntities("filetags").select() const tags = await useEntities("filetags").select()
fileData.type = tags.find(i => i.createddocumenttype === mappedType).id console.log(tags)
fileData.type = tags.find(i => i.createdDocumentType === mappedType).id
function dataURLtoFile(dataurl, filename) { function dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(","), var arr = dataurl.split(","),
@@ -1506,7 +1507,20 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
} }
if (row.service) { if (row.service) {
row.unit = service.unit ? service.unit : services.value.find(i => i.id === row.service).unit console.log(service)
if(service.unit) {
row.unit = service.unit
} else {
let selectedService = services.value.find(i => i.id === row.service)
console.log(selectedService)
if(selectedService.unit?.id) {
row.unit = selectedService.unit.id
} else {
row.unit = selectedService.unit
}
}
//row.unit = service.unit ? service.unit : services.value.find(i => i.id === row.service).unit
row.inputPrice = ((service.sellingPriceComposed.total || service.sellingPrice) ? (service.sellingPriceComposed.total || service.sellingPrice) : (services.value.find(i => i.id === row.service).sellingPriceComposed.total || services.value.find(i => i.id === row.service).sellingPrice)) row.inputPrice = ((service.sellingPriceComposed.total || service.sellingPrice) ? (service.sellingPriceComposed.total || service.sellingPrice) : (services.value.find(i => i.id === row.service).sellingPriceComposed.total || services.value.find(i => i.id === row.service).sellingPrice))
row.description = service.description ? service.description : (services.value.find(i => i.id === row.service) ? services.value.find(i => i.id === row.service).description : "") row.description = service.description ? service.description : (services.value.find(i => i.id === row.service) ? services.value.find(i => i.id === row.service).description : "")
@@ -1520,14 +1534,14 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
if (row.product) { if (row.product) {
console.log("Product Detected") console.log("Product Detected")
row.unit = product.unit ? product.unit : products.value.find(i => i.id === row.product).unit row.unit = product.unit ? product.unit : products.value.find(i => i.id === row.product).unit
row.inputPrice = (product.sellingPrice ? product.sellingPrice : products.value.find(i => i.id === row.product).sellingPrice) row.inputPrice = (product.selling_price ? product.selling_price : products.value.find(i => i.id === row.product).selling_price)
//row.price = Number((row.originalPrice * (1 + itemInfo.value.customSurchargePercentage /100)).toFixed(2)) //row.price = Number((row.originalPrice * (1 + itemInfo.value.customSurchargePercentage /100)).toFixed(2))
row.description = product.description ? product.description : (products.value.find(i => i.id === row.product) ? products.value.find(i => i.id === row.product).description : "") row.description = product.description ? product.description : (products.value.find(i => i.id === row.product) ? products.value.find(i => i.id === row.product).description : "")
if (['13b UStG', '19 UStG'].includes(itemInfo.value.taxType)) { if (['13b UStG', '19 UStG'].includes(itemInfo.value.taxType)) {
row.taxPercent = 0 row.taxPercent = 0
} else { } else {
row.taxPercent = product.taxPercentage ? product.taxPercentage : products.value.find(i => i.id === row.product).taxPercentage row.taxPercent = product.tax_percentage ? product.tax_percentage : products.value.find(i => i.id === row.product).tax_percentage
} }
} }
@@ -1584,7 +1598,6 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<UTabs class="p-5" :items="tabItems" @change="onChangeTab" v-if="loaded" v-model="selectedTab"> <UTabs class="p-5" :items="tabItems" @change="onChangeTab" v-if="loaded" v-model="selectedTab">
<template #item="{item}"> <template #item="{item}">
<div v-if="item.label === 'Editor'"> <div v-if="item.label === 'Editor'">
<UAlert <UAlert
class="my-5" class="my-5"
title="Vorhandene Probleme und Informationen:" title="Vorhandene Probleme und Informationen:"

View File

@@ -1,26 +1,26 @@
<template> <template>
<UDashboardNavbar title="Ausgangsbelege" :badge="filteredRows.length"> <UDashboardNavbar :badge="filteredRows.length" title="Ausgangsbelege">
<template #right> <template #right>
<UInput <UInput
id="searchinput" id="searchinput"
v-model="searchString" v-model="searchString"
icon="i-heroicons-funnel"
autocomplete="off" autocomplete="off"
placeholder="Suche..."
class="hidden lg:block" class="hidden lg:block"
@keydown.esc="$event.target.blur()" icon="i-heroicons-funnel"
placeholder="Suche..."
@change="tempStore.modifySearchString('createddocuments',searchString)" @change="tempStore.modifySearchString('createddocuments',searchString)"
@keydown.esc="$event.target.blur()"
> >
<template #trailing> <template #trailing>
<UKbd value="/" /> <UKbd value="/"/>
</template> </template>
</UInput> </UInput>
<UButton <UButton
v-if="searchString.length > 0"
color="rose"
icon="i-heroicons-x-mark" icon="i-heroicons-x-mark"
variant="outline" variant="outline"
color="rose"
@click="clearSearchString()" @click="clearSearchString()"
v-if="searchString.length > 0"
/> />
<UButton <UButton
@click="router.push(`/createDocument/edit`)" @click="router.push(`/createDocument/edit`)"
@@ -33,27 +33,27 @@
<template #right> <template #right>
<USelectMenu <!-- <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns" :options="templateColumns"
multiple
class="hidden lg:block"
by="key" by="key"
class="hidden lg:block"
icon="i-heroicons-adjustments-horizontal-solid"
multiple
@change="tempStore.modifyColumns('createddocuments',selectedColumns)" @change="tempStore.modifyColumns('createddocuments',selectedColumns)"
> >
<template #label> <template #label>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>-->
<USelectMenu <USelectMenu
v-if="selectableFilters.length > 0" v-if="selectableFilters.length > 0"
v-model="selectedFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:options="selectableFilters"
:ui-menu="{ width: 'min-w-max' }"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
multiple multiple
v-model="selectedFilters"
:options="selectableFilters"
:color="selectedFilters.length > 0 ? 'primary' : 'white'"
:ui-menu="{ width: 'min-w-max' }"
> >
<template #label> <template #label>
Filter Filter
@@ -63,36 +63,36 @@
</UDashboardToolbar> </UDashboardToolbar>
<UTabs :items="selectedTypes" class="m-3"> <UTabs :items="selectedTypes" class="m-3">
<template #default="{item}"> <template #default="{item}">
{{item.label}} {{ item.label }}
<UBadge <UBadge
variant="outline"
class="ml-2" class="ml-2"
variant="outline"
> >
{{filteredRows.filter(i => item.key === 'invoices' ? ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type) : item.key === i.type).length}} {{ filteredRows.filter(i => item.key === 'invoices' ? ['invoices', 'advanceInvoices', 'cancellationInvoices'].includes(i.type) : item.key === i.type).length }}
</UBadge> </UBadge>
</template> </template>
<template #item="{item}"> <template #item="{item}">
<div style="height: 80vh; overflow-y: scroll"> <div style="height: 80vh; overflow-y: scroll">
<UTable <UTable
:rows="filteredRows.filter(i => item.key === 'invoices' ? ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type) : item.key === i.type)"
:columns="columns" :columns="columns"
class="w-full"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
@select="selectItem"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }" :empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Belege anzuzeigen' }"
:rows="filteredRows.filter(i => item.key === 'invoices' ? ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type) : item.key === i.type)"
:ui="{ divide: 'divide-gray-200 dark:divide-gray-800' }"
class="w-full"
@select="selectItem"
> >
<template #type-data="{row}"> <template #type-data="{row}">
{{dataStore.documentTypesForCreation[row.type].labelSingle}} {{ dataStore.documentTypesForCreation[row.type].labelSingle }}
<!-- <!--
<span v-if="row.type === 'cancellationInvoices'"> zu {{row.linkedDocument.documentNumber}}</span> <span v-if="row.type === 'cancellationInvoices'"> zu {{row.linkedDocument.documentNumber}}</span>
--> -->
</template> </template>
<template #state-data="{row}"> <template #state-data="{row}">
<span <span
v-if="row.state === 'Entwurf'" v-if="row.state === 'Entwurf'"
class="text-rose-500" class="text-rose-500"
> >
{{row.state}} {{ row.state }}
</span> </span>
<!-- <span <!-- <span
v-if="row.state === 'Gebucht'" v-if="row.state === 'Gebucht'"
@@ -101,58 +101,59 @@
{{row.state}} {{row.state}}
</span>--> </span>-->
<span <span
v-if="row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)" v-if="row.state === 'Gebucht' && !items.find(i => i.createddocument && i.createddocument.id === row.id)"
class="text-primary-500" class="text-primary-500"
> >
{{row.state}} {{ row.state }}
</span> </span>
<span <span
v-else-if="row.state === 'Gebucht' && items.find(i => i.linkedDocument && i.linkedDocument.id === row.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type)" v-else-if="row.state === 'Gebucht' && items.find(i => i.createddocument && i.createddocument.id === row.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type)"
class="text-cyan-500" class="text-cyan-500"
> >
Storniert mit {{items.find(i => i.linkedDocument && i.linkedDocument.id === row.id).documentNumber}} Storniert mit {{ items.find(i => i.createddocument && i.createddocument.id === row.id).documentNumber }}
</span> </span>
<span <span
v-else-if="row.state === 'Gebucht'" v-else-if="row.state === 'Gebucht'"
class="text-primary-500" class="text-primary-500"
> >
{{row.state}} {{ row.state }}
</span> </span>
</template> </template>
<template #partner-data="{row}"> <template #partner-data="{row}">
<span v-if="row.customer && row.customer.name.length <21">{{row.customer ? row.customer.name : ""}}</span> <span v-if="row.customer && row.customer.name.length <21">{{ row.customer ? row.customer.name : "" }}</span>
<UTooltip v-else-if="row.customer && row.customer.name.length > 20" :text="row.customer.name"> <UTooltip v-else-if="row.customer && row.customer.name.length > 20" :text="row.customer.name">
{{row.customer.name.substring(0,20)}}... {{ row.customer.name.substring(0, 20) }}...
</UTooltip> </UTooltip>
</template> </template>
<template #reference-data="{row}"> <template #reference-data="{row}">
<span v-if="row === filteredRows[selectedItem]" class="text-primary-500 font-bold">{{row.documentNumber}}</span> <span v-if="row === filteredRows[selectedItem]"
<span v-else>{{row.documentNumber}}</span> class="text-primary-500 font-bold">{{ row.documentNumber }}</span>
<span v-else>{{ row.documentNumber }}</span>
</template> </template>
<template #date-data="{row}"> <template #date-data="{row}">
<span v-if="row.date">{{row.date ? dayjs(row.date).format("DD.MM.YY") : ''}}</span> <span v-if="row.date">{{ row.date ? dayjs(row.date).format("DD.MM.YY") : '' }}</span>
<span v-if="row.documentDate">{{row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : ''}}</span> <span
v-if="row.documentDate">{{ row.documentDate ? dayjs(row.documentDate).format("DD.MM.YY") : '' }}</span>
</template> </template>
<template #dueDate-data="{row}"> <template #dueDate-data="{row}">
<!-- <span
<span v-if="row.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)" :class="dayjs(row.documentDate).add(row.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row) ? ['text-rose-500'] : '' ">{{row.documentDate ? dayjs(row.documentDate).add(row.paymentDays,'day').format("DD.MM.YY") : ''}}</span> v-if="row.state === 'Gebucht' && row.paymentDays && ['invoices','advanceInvoices'].includes(row.type) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)"
--> :class="dayjs(row.documentDate).add(row.paymentDays,'day').diff(dayjs()) <= 0 && !isPaid(row) ? ['text-rose-500'] : '' ">{{ row.documentDate ? dayjs(row.documentDate).add(row.paymentDays, 'day').format("DD.MM.YY") : '' }}</span>
</template> </template>
<template #paid-data="{row}"> <template #paid-data="{row}">
<!-- <div v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)"> <div
v-if="(row.type === 'invoices' ||row.type === 'advanceInvoices') && row.state === 'Gebucht' && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id)">
<span v-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span> <span v-if="useSum().getIsPaid(row,items)" class="text-primary-500">Bezahlt</span>
<span v-else class="text-rose-600">Offen</span> <span v-else class="text-rose-600">Offen</span>
</div>--> </div>
</template> </template>
<template #amount-data="{row}"> <template #amount-data="{row}">
<!-- <span
<span v-if="row.type !== 'deliveryNotes'">{{displayCurrency(useSum().getCreatedDocumentSum(row,items))}}</span> v-if="row.type !== 'deliveryNotes'">{{ displayCurrency(useSum().getCreatedDocumentSum(row, items)) }}</span>
-->
</template> </template>
<template #amountOpen-data="{row}"> <template #amountOpen-data="{row}">
<!-- <span
<span v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !useSum().getIsPaid(row,items) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id) ">{{displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n,{amount}) => n + amount, 0))}}</span> v-if="!['deliveryNotes','cancellationInvoices','quotes','confirmationOrders'].includes(row.type) && row.state !== 'Entwurf' && !useSum().getIsPaid(row,items) && !items.find(i => i.linkedDocument && i.linkedDocument.id === row.id) ">{{ displayCurrency(useSum().getCreatedDocumentSum(row, items) - row.statementallocations.reduce((n, {amount}) => n + amount, 0)) }}</span>
-->
</template> </template>
</UTable> </UTable>
</div> </div>
@@ -181,14 +182,14 @@ defineShortcuts({
} }
}, },
'arrowdown': () => { 'arrowdown': () => {
if(selectedItem.value < filteredRows.value.length - 1) { if (selectedItem.value < filteredRows.value.length - 1) {
selectedItem.value += 1 selectedItem.value += 1
} else { } else {
selectedItem.value = 0 selectedItem.value = 0
} }
}, },
'arrowup': () => { 'arrowup': () => {
if(selectedItem.value === 0) { if (selectedItem.value === 0) {
selectedItem.value = filteredRows.value.length - 1 selectedItem.value = filteredRows.value.length - 1
} else { } else {
selectedItem.value -= 1 selectedItem.value -= 1
@@ -208,7 +209,7 @@ const selectedItem = ref(0)
const setupPage = async () => { const setupPage = async () => {
items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)","documentNumber",true, true)) items.value = (await useEntities("createddocuments").select("*, customer(id,name), statementallocations(id,amount),linkedDocument(*)", "documentNumber", true, true))
} }
setupPage() setupPage()
@@ -315,7 +316,7 @@ const filteredRows = computed(() => {
} }
}) })
if(selectedFilters.value.length > 0) { if (selectedFilters.value.length > 0) {
selectedFilters.value.forEach(filterName => { selectedFilters.value.forEach(filterName => {
let filter = dataType.filters.find(i => i.name === filterName) let filter = dataType.filters.find(i => i.name === filterName)
tempItems = tempItems.filter(filter.filterFunction) tempItems = tempItems.filter(filter.filterFunction)
@@ -323,7 +324,6 @@ const filteredRows = computed(() => {
} }
tempItems = useSearch(searchString.value, tempItems) tempItems = useSearch(searchString.value, tempItems)

View File

@@ -1,5 +1,3 @@
<template> <template>
<UDashboardNavbar title="Serienrechnungen" :badge="filteredRows.length"> <UDashboardNavbar title="Serienrechnungen" :badge="filteredRows.length">
<template #right> <template #right>
@@ -25,7 +23,7 @@
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardToolbar> <UDashboardToolbar>
<template #right> <template #right>
<USelectMenu <!-- <USelectMenu
v-model="selectedColumns" v-model="selectedColumns"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
:options="templateColumns" :options="templateColumns"
@@ -36,7 +34,7 @@
<template #label> <template #label>
Spalten Spalten
</template> </template>
</USelectMenu> </USelectMenu>-->
<USelectMenu <USelectMenu
v-model="selectedFilters" v-model="selectedFilters"
icon="i-heroicons-adjustments-horizontal-solid" icon="i-heroicons-adjustments-horizontal-solid"
@@ -65,26 +63,6 @@
<template #type-data="{row}"> <template #type-data="{row}">
{{dataStore.documentTypesForCreation[row.type].labelSingle}} {{dataStore.documentTypesForCreation[row.type].labelSingle}}
</template> </template>
<template #state-data="{row}">
<span
v-if="row.state === 'Entwurf'"
class="text-rose-500"
>
{{row.state}}
</span>
<span
v-if="row.state === 'Gebucht'"
class="text-cyan-500"
>
{{row.state}}
</span>
<span
v-if="row.state === 'Abgeschlossen'"
class="text-primary-500"
>
{{row.state}}
</span>
</template>
<template #partner-data="{row}"> <template #partner-data="{row}">
<span v-if="row.customer">{{row.customer ? row.customer.name : ""}}</span> <span v-if="row.customer">{{row.customer ? row.customer.name : ""}}</span>
@@ -116,6 +94,10 @@
<template #contract-data="{row}"> <template #contract-data="{row}">
<span v-if="row.contract">{{row.contract.contractNumber}} - {{row.contract.name}}</span> <span v-if="row.contract">{{row.contract.contractNumber}} - {{row.contract.name}}</span>
</template> </template>
<template #serialConfig.intervall-data="{row}">
<span v-if="row.serialConfig?.intervall === 'monatlich'">Monatlich</span>
<span v-if="row.serialConfig?.intervall === 'vierteljährlich'">Quartalsweise</span>
</template>
</UTable> </UTable>
</template> </template>
@@ -161,13 +143,9 @@ const filteredRows = computed(() => {
const templateColumns = [ const templateColumns = [
{ {
key: 'type', key: 'serialConfig.active',
label: "Typ" label: "Aktiv"
},{ },{
key: 'state',
label: "Status"
},
{
key: "amount", key: "amount",
label: "Betrag" label: "Betrag"
}, },
@@ -180,8 +158,8 @@ const templateColumns = [
label: "Vertrag" label: "Vertrag"
}, },
{ {
key: 'serialConfig.active', key: 'serialConfig.intervall',
label: "Aktiv" label: "Rhythmus"
} }
] ]
const selectedColumns = ref(templateColumns) const selectedColumns = ref(templateColumns)

View File

@@ -39,9 +39,9 @@ const openEmail = () => {
const openBankstatements = () => { const openBankstatements = () => {
if(itemInfo.value.statementallocations.length > 1) { if(itemInfo.value.statementallocations.length > 1) {
navigateTo(`/banking/?filter=${JSON.stringify(itemInfo.value.statementallocations.map(i => i.bs_id))}`) navigateTo(`/banking/?filter=${JSON.stringify(itemInfo.value.statementallocations.map(i => i.bankstatement))}`)
} else { } else {
navigateTo(`/banking/statements/edit/${itemInfo.value.statementallocations[0].bs_id}`) navigateTo(`/banking/statements/edit/${itemInfo.value.statementallocations[0].bankstatement}`)
} }
} }
</script> </script>
@@ -105,12 +105,21 @@ const openBankstatements = () => {
Kunde Kunde
</UButton> </UButton>
<UButton <UButton
v-if="itemInfo.linkedDocument" v-if="itemInfo.createddocument"
@click="router.push(`/standardEntity/createDocument/show/${itemInfo.linkedDocument}`)" @click="router.push(`/createDocument/show/${itemInfo.createddocument}`)"
icon="i-heroicons-link" icon="i-heroicons-link"
variant="outline" variant="outline"
> >
{{dataStore.documentTypesForCreation[itemInfo.linkedDocument.type].labelSingle}} - {{itemInfo.linkedDocument.documentNumber}} {{dataStore.documentTypesForCreation[itemInfo.createddocument.type].labelSingle}} - {{itemInfo.createddocument.documentNumber}}
</UButton>
<UButton
v-for="item in itemInfo.createddocuments"
v-if="itemInfo.createddocuments"
@click="router.push(`/createDocument/show/${item.id}`)"
icon="i-heroicons-link"
variant="outline"
>
{{dataStore.documentTypesForCreation[item.type].labelSingle}} - {{item.documentNumber}}
</UButton> </UButton>
<UButton <UButton
v-if="itemInfo.statementallocations?.length > 0" v-if="itemInfo.statementallocations?.length > 0"

View File

@@ -48,6 +48,15 @@ const setupPage = async () => {
if(data) loadedDocuments.value = data if(data) loadedDocuments.value = data
loadedDocuments.value = await Promise.all(loadedDocuments.value.map(async doc => {
const document = await useEntities("createddocuments").selectSingle(doc.createddocument)
console.log(document)
return {
...doc,
createddocument: document
}}))
//console.log(loadedDocuments.value) //console.log(loadedDocuments.value)
if(loadedDocuments.value.length > 0) { if(loadedDocuments.value.length > 0) {

View File

@@ -364,33 +364,67 @@ const clearSearchString = () => {
variant="outline" variant="outline"
v-if="Object.keys(selectedFiles).find(i => selectedFiles[i] === true)" v-if="Object.keys(selectedFiles).find(i => selectedFiles[i] === true)"
>Herunterladen</UButton> >Herunterladen</UButton>
<UModal <UModal v-model="createFolderModalOpen">
v-model="createFolderModalOpen" <UCard :ui="{ body: { base: 'space-y-4' } }">
>
<UCard>
<template #header> <template #header>
Ordner Erstellen <div class="flex items-center justify-between">
</template> <h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
Ordner Erstellen
</h3>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="createFolderModalOpen = false" />
</div>
</template>
<UFormGroup label="Name des Ordners" required>
<UInput
v-model="createFolderData.name"
placeholder="z.B. Rechnungen 2024"
autofocus
/>
</UFormGroup>
<UFormGroup
<UFormGroup label="Standard Dateityp"
label="Ordner erstellen" >
<USelectMenu
v-model="createFolderData.standardFiletype"
:options="filetags"
option-attribute="name"
value-attribute="id"
searchable
searchable-placeholder="Typ suchen..."
placeholder="Kein Standard-Typ"
clear-search-on-close
> >
<UInput <template #label>
v-model="createFolderData.name" <span v-if="createFolderData.standardFiletype">
{{ filetags.find(t => t.id === createFolderData.standardFiletype)?.name }}
</span>
<span v-else class="text-gray-400">Kein Typ ausgewählt</span>
</template>
</USelectMenu>
</UFormGroup>
<div v-if="createFolderData.standardFiletype">
<UCheckbox
v-model="createFolderData.standardFiletypeIsOptional"
name="isOptional"
label="Dateityp ist optional"
help="Wenn deaktiviert, MUSS der Nutzer beim Upload diesen Typ verwenden."
/> />
</UFormGroup> </div>
<template #footer> <template #footer>
<UButton <div class="flex justify-end gap-2">
@click="createFolder" <UButton color="gray" variant="ghost" @click="createFolderModalOpen = false">
> Abbrechen
Erstellen </UButton>
</UButton> <UButton @click="createFolder" :disabled="!createFolderData.name">
Erstellen
</UButton>
</div>
</template> </template>
</UCard> </UCard>
</UModal> </UModal>
</template> </template>
@@ -429,8 +463,8 @@ const clearSearchString = () => {
</td> </td>
<td> <td>
<span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).created_at).format("DD.MM.YY HH:mm")}}</span> <span v-if="entry.type === 'file'" class="text-xl">{{dayjs(documents.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
<span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).created_at).format("DD.MM.YY HH:mm")}}</span> <span v-if="entry.type === 'folder'" class="text-xl">{{dayjs(currentFolders.find(i => i.id === entry.id).createdAt).format("DD.MM.YY HH:mm")}}</span>
</td> </td>
</tr> </tr>
</table> </table>

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";
@@ -7,25 +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()
// 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 view = ref('list') // 'list' | 'timeline'
const editEntry = ref(null)
// 👥 Nutzer-Filter (nur für Berechtigte) // 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([])
const selectedUser = ref(platformIsNative ? auth.user.id : null) 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'))
// 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 = { const typeLabel = {
work: "Arbeitszeit", work: "Arbeitszeit",
vacation: "Urlaub", vacation: "Urlaub",
@@ -42,24 +66,20 @@ const typeColor = {
other: "gray" other: "gray"
} }
// ACTIONS
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 (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 })
)
} }
async function handleStart() { async function handleStart() {
if (!isViewingSelf.value) return
loading.value = true loading.value = true
await start("Arbeitszeit gestartet") await start("Arbeitszeit gestartet")
await load() await load()
@@ -74,366 +94,380 @@ async function handleStop() {
loading.value = false loading.value = false
} }
function handleEdit(entry: any) { function handleEdit(entry) {
editEntry.value = entry entryToEdit.value = entry
showModal.value = true showEditModal.value = true
} }
async function handleSubmit(entry: any) { async function handleSubmit(entry) {
await submit(entry.id) loading.value = true
await submit(entry)
await load() await load()
loading.value = false
toast.add({ title: 'Zeit eingereicht', color: 'green' })
} }
async function handleApprove(entry: any) { async function handleApprove(entry) {
await approve(entry.id) loading.value = true
await approve(entry)
await load() 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 () => { onMounted(async () => {
await load()
await loadUsers() await loadUsers()
await load()
setPageLayout(platformIsNative ? 'mobile' : 'default') 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-4">
<UIcon name="i-heroicons-clock" class="text-primary-500" /> <div class="flex items-center gap-2 border-r pr-4 mr-2">
<span v-if="active" class="text-primary-600 font-medium"> <UIcon name="i-heroicons-clock" class="w-5 h-5" :class="active ? 'text-primary-500 animate-pulse' : 'text-gray-400'" />
Läuft seit {{ useNuxtApp().$dayjs(active.started_at).format('HH:mm') }} <div class="flex flex-col">
</span> <span class="text-xs text-gray-500 uppercase font-bold">Status</span>
<span v-else class="text-gray-500">Keine aktive Zeit</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> </div>
</template> </template>
<template #right> <template #right>
<UButton <div class="flex gap-2 items-center">
v-if="active" <div class="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
color="red" <UTooltip text="Listenansicht">
icon="i-heroicons-stop" <UButton
:loading="loading" size="xs"
label="Stoppen" :color="view === 'list' ? 'white' : 'gray'"
@click="handleStop" variant="ghost"
/> icon="i-heroicons-table-cells"
<UButton @click="view = 'list'"
v-else />
color="green" </UTooltip>
icon="i-heroicons-play" <UTooltip text="Zeitstrahl">
:loading="loading" <UButton
label="Starten" size="xs"
@click="handleStart" :color="view === 'timeline' ? 'white' : 'gray'"
/> variant="ghost"
<UButton icon="i-heroicons-list-bullet"
color="primary" @click="view = 'timeline'"
icon="i-heroicons-plus" />
label="Zeit" </UTooltip>
@click="() => { editEntry = null; showModal = true }" </div>
/>
</template>
</UDashboardToolbar>
<UDashboardToolbar>
<template #left>
<!-- 👥 User-Filter (nur bei Berechtigung) -->
<div v-if="canViewAll" class="flex items-center gap-2">
<USelectMenu
v-model="selectedUser"
:options="[
{ label: 'Alle Benutzer', value: null },
...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]"
@change="load"
/>
<!-- 🔹 Button zur Auswertung --> <template v-if="isViewingSelf">
<UTooltip <UButton v-if="active" color="red" icon="i-heroicons-stop" :loading="loading" label="Stoppen" @click="handleStop" />
:text="selectedUser ? 'Anwesenheiten des Mitarbeiters auswerten' : 'Mitarbeiter für die Auswertung auswählen'" <UButton v-else color="green" icon="i-heroicons-play" :loading="loading" label="Starten" @click="handleStart" />
> </template>
<UButton <template v-else-if="active && canViewAll">
:disabled="!selectedUser" <UButton color="red" variant="soft" icon="i-heroicons-stop" :loading="loading" label="Mitarbeiter stoppen" @click="handleStop" />
color="gray" </template>
icon="i-heroicons-chart-bar"
label="Auswertung"
variant="soft"
@click="router.push(`/staff/time/${selectedUser}/evaluate`)"
/>
</UTooltip>
<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' }, { 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> <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>
</template> <UBadge v-else color="gray" variant="subtle">Entwurf</UBadge>
<template #type-data="{ row }"> </template>
<UBadge :color="typeColor[row.type] || 'gray'"> <template #type-data="{ row }">
{{ typeLabel[row.type] || row.type }} <UBadge :color="typeColor[row.type] || 'gray'" variant="soft">{{ typeLabel[row.type] || row.type }}</UBadge>
</UBadge> </template>
</template> <template #started_at-data="{ row }">
<span v-if="['vacation','sick'].includes(row.type)">{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }}</span>
<!-- START --> <span v-else>{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}</span>
<template #started_at-data="{ row }"> </template>
<!-- Urlaub / Krankheit nur Tag --> <template #stopped_at-data="{ row }">
<span v-if="row.type === 'vacation' || row.type === 'sick'"> <span v-if="!row.stopped_at" class="text-primary-500 font-medium animate-pulse">läuft...</span>
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY") }} <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>
</template>
<!-- Arbeitszeit / andere Datum + Uhrzeit --> <template #duration_minutes-data="{ row }">
<span v-else>
{{ useNuxtApp().$dayjs(row.started_at).format("DD.MM.YY HH:mm") }}
</span>
</template>
<!-- ENDE -->
<template #stopped_at-data="{ row }">
<span v-if="!row.stopped_at" class="text-primary-500 font-medium">
läuft...
</span>
<!-- Urlaub / Krankheit nur Tag -->
<span v-else-if="row.type === 'vacation' || row.type === 'sick'">
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY") }}
</span>
<!-- Arbeitszeit / andere Datum + Uhrzeit -->
<span v-else>
{{ useNuxtApp().$dayjs(row.stopped_at).format("DD.MM.YY HH:mm") }}
</span>
</template>
<template #duration_minutes-data="{ row }">
<!-- Urlaub / Krankheit Tage anzeigen -->
<span v-if="row.type === 'vacation' || row.type === 'sick'">
<!-- {{ useFormatDurationDays(row.startet_at, row.stopped_at) }}-->--
</span>
<!-- Arbeitszeit / andere Minutenformat -->
<span v-else>
{{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }} {{ row.duration_minutes ? useFormatDuration(row.duration_minutes) : "-" }}
</span> </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>
</template> <div v-else class="max-w-5xl mx-auto pb-20">
<template #actions-data="{ row }"> <div v-for="(group, date) in groupedEntries" :key="date" class="relative group/date">
<UTooltip text="Zeit genehmigen" v-if="row.state === 'submitted'">
<UButton <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">
variant="ghost" <div class="w-2.5 h-2.5 rounded-full bg-gray-400"></div>
icon="i-heroicons-check-circle" <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 capitalize">
@click="handleApprove(row)" {{ useNuxtApp().$dayjs(date).format('dddd, DD. MMMM') }}
/> </h3>
</UTooltip> <span class="text-xs text-gray-500 font-normal mt-0.5">
<UTooltip text="Zeit einreichen" v-if="row.state === 'draft'"> {{ group.length }} Einträge
<UButton </span>
variant="ghost" </div>
icon="i-heroicons-arrow-right-end-on-rectangle"
@click="handleSubmit(row)" <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>
/>
</UTooltip> <div class="space-y-6 pb-8">
<UTooltip text="Zeit bearbeiten" v-if="row.state === 'draft'"> <div v-for="entry in group" :key="entry.id" class="relative pl-8">
<UButton
variant="ghost" <div
icon="i-heroicons-pencil-square" class="absolute left-0 top-6 w-3 h-3 rounded-full border-2 border-white dark:border-gray-900 shadow-sm z-0"
@click="handleEdit(row)" :class="{
/> 'bg-green-500 ring-4 ring-green-100 dark:ring-green-900/30': !entry.stopped_at,
</UTooltip> 'bg-gray-400': entry.stopped_at && entry.type === 'work',
</template> 'bg-yellow-400': entry.type === 'vacation',
<template #user-data="{ row }"> 'bg-red-400': entry.type === 'sick'
{{users.find(i => i.user_id === row.user_id) ? users.find(i => i.user_id === row.user_id).full_name : ""}} }"
</template> ></div>
<template #description-data="{ row }">
<span v-if="row.type === 'vacation'">{{row.vacation_reason}}</span> <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">
<span v-else-if="row.type === 'sick'">{{row.sick_reason}}</span>
<span v-else>{{row.description}}</span> <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">
</template> <div class="flex items-center gap-3">
</UTable> <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>
<!-- ============================= -->
<!-- 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">
<div v-if="isViewingSelf" class="p-4 bg-white dark:bg-gray-900 border-b sticky top-0 z-20 shadow-sm">
<!-- 🔥 FIXED ACTIVE TIMER -->
<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 animate-pulse">
<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>
<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="Eigene Auswertung"
class="w-full"
variant="soft"
@click="router.push(`/staff/time/${auth.user.id}/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">
<UCard v-for="row in entries" :key="row.id" class="p-4 border rounded-xl" @click="handleEdit(row)">
<!-- ZEIT-CARDS -->
<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="{ <UBadge v-else-if="row.state === 'rejected'" color="red">Abgelehnt</UBadge>
approved: 'primary', <UBadge v-else color="gray">Entwurf</UBadge>
submitted: 'cyan',
draft: 'red'
}[row.state]"
>
{{
{
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>
<!-- ACTION-BUTTONS -->
<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'"
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 }" />
<!-- FLOATING ACTION BUTTON -->
<FloatingActionButton
icon="i-heroicons-plus"
class="!fixed bottom-6 right-6 z-50"
color="primary"
@click="() => { editEntry = null; showModal = true }"
/>
</div> </div>
</template> </template>
<!-- MODAL -->
<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>

View File

@@ -33,7 +33,7 @@ const setupPage = async (sort_column = null, sort_direction = null) => {
item.value = await useEntities(type).selectSingle(route.params.id, "*", true) item.value = await useEntities(type).selectSingle(route.params.id, "*", true)
} else if (mode.value === "edit") { } else if (mode.value === "edit") {
//Load Data for Edit //Load Data for Edit
item.value = JSON.stringify(await useEntities(type).selectSingle(route.params.id)) item.value = JSON.stringify(await useEntities(type).selectSingle(route.params.id,"*",false))
console.log(item.value) console.log(item.value)

View File

@@ -956,10 +956,10 @@ export const useDataStore = defineStore('data', () => {
inputType: "number", inputType: "number",
inputTrailing: "EUR", inputTrailing: "EUR",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
if(row.markupPercentage) { if(row.markup_percentage) {
row.sellingPrice = (row.purchasePrice * (1+row.markupPercentage/100)).toFixed(4) row.seling_price = (row.purchase_price * (1+row.markup_percentage/100)).toFixed(4)
} else { } else {
row.sellingPrice = row.purchasePrice.toFixed(4) row.seling_price = row.purchase_price.toFixed(4)
} }
} }
},{ },{
@@ -968,12 +968,12 @@ export const useDataStore = defineStore('data', () => {
inputType: "number", inputType: "number",
inputTrailing: "%", inputTrailing: "%",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
if(row.purchasePrice && ! row.sellingPrice) { if(row.purchase_price && ! row.seling_price) {
row.sellingPrice = (row.purchasePrice * (1+row.markupPercentage/100)).toFixed(4) row.seling_price = (row.purchase_price * (1+row.markup_percentage/100)).toFixed(4)
} else if(row.sellingPrice && !row.purchasePrice) { } else if(row.seling_price && !row.purchase_price) {
row.purchasePrice = (row.sellingPrice / (1+row.markupPercentage/100)).toFixed(4) row.purchase_price = (row.seling_price / (1+row.markup_percentage/100)).toFixed(4)
} else { } else {
row.sellingPrice = (row.purchasePrice * (1+row.markupPercentage/100)).toFixed(4) row.seling_price = (row.purchase_price * (1+row.markup_percentage/100)).toFixed(4)
} }
} }
},{ },{
@@ -984,10 +984,10 @@ export const useDataStore = defineStore('data', () => {
inputType: "number", inputType: "number",
inputTrailing: "EUR", inputTrailing: "EUR",
inputChangeFunction: function (row) { inputChangeFunction: function (row) {
if(row.purchasePrice ) { if(row.purchase_price ) {
row.markupPercentage = ((row.sellingPrice / row.purchasePrice - 1) * 100 ).toFixed(2) row.markup_percentage = ((row.selling_price / row.purchase_price - 1) * 100 ).toFixed(2)
} else{ } else{
row.purchasePrice = (row.sellingPrice / (1+row.markupPercentage/100)).toFixed(4) row.purchase_price = (row.selling_price / (1+row.markup_percentage/100)).toFixed(4)
} }
} }
},{ },{
@@ -1766,7 +1766,7 @@ export const useDataStore = defineStore('data', () => {
sortable: true sortable: true
}, },
{ {
key: "purchasePrice", key: "purchase_price",
label: "Kaufpreis", label: "Kaufpreis",
inputType: "number", inputType: "number",
inputStepSize: "0.01", inputStepSize: "0.01",
@@ -2108,7 +2108,7 @@ export const useDataStore = defineStore('data', () => {
sortable: true sortable: true
}, },
{ {
key: "purchasePrice", key: "purchase_price",
label: "Einkauspreis", label: "Einkauspreis",
inputType: "number", inputType: "number",
component: purchasePrice, component: purchasePrice,