diff --git a/components/StaffTimeEntryModal.vue b/components/StaffTimeEntryModal.vue index e08ff26..dcf370f 100644 --- a/components/StaffTimeEntryModal.vue +++ b/components/StaffTimeEntryModal.vue @@ -1,206 +1,170 @@ - - + \ No newline at end of file diff --git a/composables/useStaffTime.ts b/composables/useStaffTime.ts index b19145d..791c0a0 100644 --- a/composables/useStaffTime.ts +++ b/composables/useStaffTime.ts @@ -5,156 +5,115 @@ export const useStaffTime = () => { const { $api, $dayjs } = useNuxtApp() const auth = useAuthStore() - /** - * Lädt die Zeitspannen (Spans) für die Liste. - * Nutzt jetzt GET /api/staff/time/spans - */ + // ... (list Funktion bleibt gleich) ... const list = async (filter?: { user_id?: string, from?: string, to?: string }) => { - // Standard: Aktueller Monat + // ... (Code wie zuvor) const from = filter?.from || $dayjs().startOf('month').format('YYYY-MM-DD') const to = filter?.to || $dayjs().endOf('month').format('YYYY-MM-DD') - - // Ziel-User: Entweder aus Filter (Admin-Ansicht) oder eigener User const targetUserId = filter?.user_id || auth.user.id - - const params = new URLSearchParams({ - from, - to, - // Der Endpoint erwartet targetUserId, wenn man Daten eines anderen Users will - targetUserId - }) - + const params = new URLSearchParams({ from, to, targetUserId }) try { - // 💡 UPDATE: Prefix /api hinzugefügt const spans = await $api(`/api/staff/time/spans?${params.toString()}`) - return (spans || []).map((span: any) => { const start = $dayjs(span.startedAt) - // Wenn endedAt null ist, läuft die Zeit noch -> Dauer bis "jetzt" berechnen für Anzeige const end = span.endedAt ? $dayjs(span.endedAt) : $dayjs() - const duration = end.diff(start, 'minute') - return { - // ID: Wir nehmen die erste Event-ID (Start-Event) als Referenz für Aktionen id: span.sourceEventIds && span.sourceEventIds.length > 0 ? span.sourceEventIds[0] : null, - - // Mapping Backend-Status -> Frontend-State + eventIds: span.sourceEventIds || [], state: span.status, - - // Zeitstempel started_at: span.startedAt, stopped_at: span.endedAt, - - duration_minutes: duration, - - // Da der Endpoint nur die Spans zurückgibt, setzen wir die UserID - // auf den User, den wir angefragt haben. + duration_minutes: end.diff(start, 'minute'), user_id: targetUserId, - type: span.type, - - // Payload/Description falls vorhanden description: span.payload?.description || '' } - }).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) // Sortierung: Neueste oben - + }).sort((a: any, b: any) => $dayjs(b.started_at).valueOf() - $dayjs(a.started_at).valueOf()) } catch (error) { - console.error("Fehler beim Laden der Zeiten:", error) + console.error("Fehler beim Laden:", error) return [] } } /** - * Startet einen neuen Zeiteintrag. - * POST /api/staff/time/event + * 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") => { - try { - // 💡 UPDATE: Prefix /api hinzugefügt - await $api('/api/staff/time/event', { - method: 'POST', - body: { - eventtype: 'work_start', - eventtime: new Date().toISOString(), - payload: { description } - } - }) - } catch (error) { - console.error("Fehler beim Starten:", error) - throw error - } + const start = async (description = "Arbeitszeit", time?: string) => { + await $api('/api/staff/time/event', { + method: 'POST', + body: { + eventtype: 'work_start', + eventtime: time || new Date().toISOString(), // 💡 Fix: Zeit akzeptieren + payload: { description } + } + }) } - /** - * Stoppt die aktuelle Zeit. - * POST /api/staff/time/event - */ - const stop = async (entryId?: string) => { - try { - // 💡 UPDATE: Prefix /api hinzugefügt - await $api('/api/staff/time/event', { - method: 'POST', - body: { - eventtype: 'work_end', - eventtime: new Date().toISOString() - } - }) - } catch (error) { - console.error("Fehler beim Stoppen:", error) - throw error - } + const stop = async () => { + await $api('/api/staff/time/event', { method: 'POST', body: { eventtype: 'work_end', eventtime: new Date().toISOString() } }) } - /** - * Reicht einen Eintrag ein. - * POST /api/staff/time/submit - */ - const submit = async (entryId: string) => { - if (!entryId) return - try { - // 💡 UPDATE: Prefix /api hinzugefügt - await $api('/api/staff/time/submit', { - method: 'POST', - body: { - eventIds: [entryId] - } - }) - } catch (error) { - console.error("Fehler beim Einreichen:", error) - throw error - } + const submit = async (entry: any) => { + const ids = entry.eventIds || (entry.id ? [entry.id] : [entry]); + if (!ids || ids.length === 0) return + await $api('/api/staff/time/submit', { method: 'POST', body: { eventIds: ids } }) } - /** - * Genehmigt einen Eintrag. - * POST /api/staff/time/approve - */ const approve = async (entry: any) => { - if (!entry || !entry.id || !entry.user_id) { - console.error("Ungültiger Eintrag für Genehmigung (ID oder UserID fehlt)", entry) - return - } + if (!entry?.user_id) return + const ids = entry.eventIds || [entry.id]; + await $api('/api/staff/time/approve', { method: 'POST', body: { eventIds: ids, employeeUserId: entry.user_id } }) + } - try { - // 💡 UPDATE: Prefix /api hinzugefügt - await $api('/api/staff/time/approve', { + const reject = async (entry: any, reason = "Abgelehnt") => { + if (!entry?.user_id) return + const ids = entry.eventIds || [entry.id]; + await $api('/api/staff/time/reject', { method: 'POST', body: { eventIds: ids, employeeUserId: entry.user_id, reason } }) + } + + const update = async (entry: any, newData: { start: string, end: string | null, type: string, description: string }) => { + if (!entry || !entry.eventIds || entry.eventIds.length === 0) { + throw new Error("Bearbeiten fehlgeschlagen: Keine IDs.") + } + await $api('/api/staff/time/edit', { + method: 'POST', + body: { + originalEventIds: entry.eventIds, + newStart: newData.start, + newEnd: newData.end, + newType: newData.type, + description: newData.description, + reason: "Manuelle Bearbeitung" + } + }) + } + + // 🆕 NEU: Manuellen Eintrag erstellen (Vergangenheit oder Zeitraum) + const createEntry = async (data: { start: string, end: string | null, type: string, description: string }) => { + // 1. Start Event senden + // Wir nutzen den dynamischen Typ (work_start, vacation_start etc.) + await $api('/api/staff/time/event', { + 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: { - eventIds: [entry.id], - employeeUserId: entry.user_id + eventtype: `${data.type}_end`, + eventtime: data.end } }) - } catch (error) { - console.error("Fehler beim Genehmigen:", error) - throw error } } - return { - list, - start, - stop, - submit, - approve - } + return { list, start, stop, submit, approve, reject, update, createEntry } } \ No newline at end of file diff --git a/package.json b/package.json index cc0dc96..d1a6d42 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@fullcalendar/timegrid": "^6.1.10", "@fullcalendar/vue3": "^6.1.10", "@iconify/json": "^2.2.171", + "@mmote/niimbluelib": "^0.0.1-alpha.29", "@nuxt/ui-pro": "^1.6.0", "@nuxtjs/fontaine": "^0.4.1", "@nuxtjs/google-fonts": "^3.1.0", @@ -63,7 +64,9 @@ "dayjs": "^1.11.10", "fast-sort": "^3.4.1", "handlebars": "^4.7.8", + "image-js": "^1.1.0", "leaflet": "^1.9.4", + "license-checker": "^25.0.1", "maplibre-gl": "^4.7.0", "nuxt-editorjs": "^1.0.4", "nuxt-viewport": "^2.0.6", @@ -80,6 +83,8 @@ "vue-chartjs": "^5.3.1", "vuedraggable": "^4.1.0", "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" } } \ No newline at end of file diff --git a/pages/staff/time/index.vue b/pages/staff/time/index.vue index c6c9d17..a546533 100644 --- a/pages/staff/time/index.vue +++ b/pages/staff/time/index.vue @@ -7,35 +7,49 @@ definePageMeta({ layout: "default", }) -const { list, start, stop, submit, approve } = useStaffTime() +const { list, start, stop, submit, approve, reject } = useStaffTime() const auth = useAuthStore() const router = useRouter() +const toast = useToast() +const { $dayjs } = useNuxtApp() + // MOBILE DETECTION const platformIsNative = useCapacitor().getIsNative() // STATE const loading = ref(false) -const showModal = ref(false) -const editEntry = ref(null) +const view = ref('list') // 'list' | 'timeline' -// 👥 Nutzer-Filter +// MODAL STATES +const showEditModal = ref(false) +const entryToEdit = ref(null) + +const showRejectModal = ref(false) +const entryToReject = ref(null) +const rejectReason = ref("") + +// FILTER & USER const users = ref([]) -// WICHTIG: Standardmäßig IMMER den aktuellen User wählen (kein null mehr) const selectedUser = ref(auth.user.id) const canViewAll = computed(() => auth.permissions.includes('staff.time.read_all')) -// LIST + ACTIVE +// DATA const entries = ref([]) - -// Active berechnet sich jetzt aus den geladenen Einträgen. -// Da wir immer nur EINEN User laden, ist ein laufender Eintrag hier -// automatisch der aktive Eintrag des angezeigten Users. const active = computed(() => entries.value.find(e => !e.stopped_at)) - -// Prüft, ob wir gerade das eigene Profil ansehen (für Start/Stop Buttons) const isViewingSelf = computed(() => selectedUser.value === auth.user.id) +// GROUPING +const groupedEntries = computed(() => { + const groups = {} + entries.value.forEach(entry => { + const dateKey = $dayjs(entry.started_at).format('YYYY-MM-DD') + if (!groups[dateKey]) groups[dateKey] = [] + groups[dateKey].push(entry) + }) + return groups +}) +// CONFIG const typeLabel = { work: "Arbeitszeit", vacation: "Urlaub", @@ -52,26 +66,20 @@ const typeColor = { other: "gray" } - +// ACTIONS async function loadUsers() { if (!canViewAll.value) return const res = (await useNuxtApp().$api("/api/tenant/profiles")).data users.value = res } -// LOAD ENTRIES (Erzwingt jetzt immer eine User-ID) async function load() { if (!selectedUser.value) return - - entries.value = await list({ - user_id: selectedUser.value // 👈 Hier wird der Filter erzwungen - }) + entries.value = await list({ user_id: selectedUser.value }) } async function handleStart() { - // Sicherheitshalber: Starten nur erlauben, wenn man sich selbst ansieht if (!isViewingSelf.value) return - loading.value = true await start("Arbeitszeit gestartet") await load() @@ -80,9 +88,6 @@ async function handleStart() { async function handleStop() { if (!active.value) return - // Stoppen darf man ggf. auch andere (als Admin), daher hier kein isViewingSelf Check - // oder je nach Logik anpassen. Hier erlauben wir das Stoppen des angezeigten Timers. - loading.value = true await stop(active.value.id) await load() @@ -90,28 +95,53 @@ async function handleStop() { } function handleEdit(entry) { - editEntry.value = entry - showModal.value = true + entryToEdit.value = entry + showEditModal.value = true } async function handleSubmit(entry) { + loading.value = true await submit(entry) await load() + loading.value = false + toast.add({ title: 'Zeit eingereicht', color: 'green' }) } async function handleApprove(entry) { + loading.value = true await approve(entry) await load() + loading.value = false + toast.add({ title: 'Zeit genehmigt', color: 'green' }) } -// Watcher: Wenn der User im Dropdown gewechselt wird, neu laden -watch(selectedUser, () => { - load() -}) +function openRejectModal(entry) { + entryToReject.value = entry + rejectReason.value = "" + showRejectModal.value = true +} + +async function confirmReject() { + if (!entryToReject.value) return + loading.value = true + try { + await reject(entryToReject.value, rejectReason.value || "Vom Administrator abgelehnt") + toast.add({ title: 'Zeit abgelehnt', color: 'green' }) + showRejectModal.value = false + await load() + } catch (e) { + toast.add({ title: 'Fehler beim Ablehnen', description: e.message, color: 'red' }) + } finally { + loading.value = false + entryToReject.value = null + } +} + +watch(selectedUser, () => { load() }) onMounted(async () => { await loadUsers() - await load() // Lädt initial den eingeloggten User + await load() setPageLayout(platformIsNative ? 'mobile' : 'default') }) @@ -120,302 +150,324 @@ onMounted(async () => { + + + + +
+

+ Der Eintrag wird als "Rejected" markiert und nicht mehr zur Arbeitszeit gezählt. +

+ + + +
+ +
+
\ No newline at end of file