Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung
Features: - Backend: Node.js/Express mit MySQL/MariaDB - Frontend: Vue.js 3 mit Composition API - UTC-Zeithandling für korrekte Zeiterfassung - Timewish-basierte Überstundenberechnung - Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling - Bereinigtes Arbeitsende (Generell/Woche) - Überstunden-Offset für historische Daten - Fixed Layout mit scrollbarem Content - Kompakte UI mit grünem Theme
This commit is contained in:
522
frontend/src/components/StatusBox.vue
Normal file
522
frontend/src/components/StatusBox.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="status-box" v-if="isReady">
|
||||
<div class="status-actions">
|
||||
<!-- Linker Button -->
|
||||
<button
|
||||
v-if="leftButton"
|
||||
class="btn btn-small"
|
||||
:class="leftButton.class"
|
||||
@click="handleAction(leftButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ leftButton.label }}
|
||||
</button>
|
||||
|
||||
<!-- Rechter Button -->
|
||||
<button
|
||||
v-if="rightButton"
|
||||
class="btn btn-small btn-secondary"
|
||||
@click="handleAction(rightButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ rightButton.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="row" v-for="(value, key) in displayRows" :key="key" :class="{ 'heading-row': value === null }">
|
||||
<span class="label">{{ key }}{{ value === null ? '' : ':' }}</span>
|
||||
<span class="value" v-if="value !== null">{{ value || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-box loading" v-else>
|
||||
Lädt…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
const authStore = useAuthStore()
|
||||
const stats = ref({})
|
||||
const currentState = ref(null) // 'null', 'start work', 'start pause', 'stop pause'
|
||||
const isReady = ref(false)
|
||||
const loading = ref(false)
|
||||
const workStartTime = ref(null) // Timestamp wann die Arbeit begonnen hat
|
||||
const lastPauseStartTime = ref(null) // Timestamp der aktuellen Pause
|
||||
const pauseDurations = ref([]) // Array von Pausen-Dauern in Millisekunden
|
||||
const currentlyWorkedTime = ref('—') // Berechnete Arbeitszeit
|
||||
const openTime = ref('—') // Berechnete offene Zeit
|
||||
const regularEndTime = ref('—') // Berechnetes normales Arbeitsende
|
||||
const serverWorkedTime = ref(null) // Vom Server berechnete Zeit
|
||||
const serverOpenTime = ref(null) // Vom Server berechnete offene Zeit
|
||||
const serverTimestamp = ref(null) // Wann die Server-Daten berechnet wurden
|
||||
const missingBreakMinutes = ref(0) // Fehlende Pausenminuten
|
||||
let dataFetchInterval = null // Daten vom Server laden
|
||||
let displayUpdateInterval = null // Anzeige aktualisieren
|
||||
|
||||
const fetchStats = async () => {
|
||||
const data = await timeStore.fetchStats()
|
||||
stats.value = data || {}
|
||||
|
||||
// Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp
|
||||
if (data?.currentlyWorked) {
|
||||
serverWorkedTime.value = data.currentlyWorked
|
||||
} else {
|
||||
serverWorkedTime.value = null
|
||||
}
|
||||
|
||||
if (data?.open) {
|
||||
serverOpenTime.value = data.open
|
||||
} else {
|
||||
serverOpenTime.value = null
|
||||
}
|
||||
|
||||
if (data?.timestamp) {
|
||||
serverTimestamp.value = new Date(data.timestamp).getTime()
|
||||
}
|
||||
|
||||
// Hole fehlende Pausenminuten
|
||||
if (data?.missingBreakMinutes !== undefined) {
|
||||
missingBreakMinutes.value = data.missingBreakMinutes
|
||||
} else {
|
||||
missingBreakMinutes.value = 0
|
||||
}
|
||||
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const fetchCurrentState = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/current-state', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
currentState.value = result.state || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des aktuellen Zustands:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Lade die aktuellen Worklog-Daten (nur einmal pro Minute)
|
||||
const fetchWorklogData = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/running', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
|
||||
console.log('DEBUG fetchWorklogData: result =', result)
|
||||
console.log('DEBUG fetchWorklogData: currentState =', currentState.value)
|
||||
|
||||
// Das Backend gibt direkt das Entry-Objekt zurück, nicht { entry: ... }
|
||||
if (result && result.startTime && (currentState.value === 'start work' || currentState.value === 'stop pause')) {
|
||||
// Arbeit läuft
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
lastPauseStartTime.value = null
|
||||
console.log('DEBUG: Arbeit läuft, startTime:', result.startTime, 'pauses:', pauseDurations.value.length)
|
||||
} else if (result && result.startTime && currentState.value === 'start pause') {
|
||||
// In Pause
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
// Hole letzten Pause-Start
|
||||
if (result.currentPauseStart) {
|
||||
lastPauseStartTime.value = new Date(result.currentPauseStart).getTime()
|
||||
}
|
||||
console.log('DEBUG: In Pause, startTime:', result.startTime, 'currentPauseStart:', result.currentPauseStart)
|
||||
} else {
|
||||
// Nicht am Arbeiten
|
||||
workStartTime.value = null
|
||||
lastPauseStartTime.value = null
|
||||
pauseDurations.value = []
|
||||
console.log('DEBUG: Nicht am Arbeiten')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Worklog-Daten:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die aktuell gearbeitete Zeit (2x pro Sekunde)
|
||||
const updateCurrentlyWorkedTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverWorkedTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverWorkedTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht hoch
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Addiere vergangene Zeit
|
||||
const totalSeconds = serverSeconds + elapsedSeconds
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
} else {
|
||||
// Fallback: Wenn kein Server-Wert, aber workStartTime vorhanden
|
||||
if (!workStartTime.value) {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let totalWorkedMs = now - workStartTime.value
|
||||
|
||||
// Ziehe abgeschlossene Pausen ab
|
||||
const totalPauseMs = pauseDurations.value.reduce((sum, duration) => sum + duration, 0)
|
||||
totalWorkedMs -= totalPauseMs
|
||||
|
||||
// Wenn aktuell in Pause, ziehe die laufende Pause ab
|
||||
if (currentState.value === 'start pause' && lastPauseStartTime.value) {
|
||||
const currentPauseMs = now - lastPauseStartTime.value
|
||||
totalWorkedMs -= currentPauseMs
|
||||
}
|
||||
|
||||
// Formatiere als HH:MM:SS
|
||||
const totalSeconds = Math.floor(totalWorkedMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die offene Zeit (2x pro Sekunde)
|
||||
const updateOpenTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverOpenTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverOpenTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht runter (bleibt gleich)
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Subtrahiere vergangene Zeit (Offen wird weniger)
|
||||
const remainingSeconds = serverSeconds - elapsedSeconds
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
const hours = Math.floor(remainingSeconds / 3600)
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
|
||||
openTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
// Berechne "Normales Arbeitsende" = Jetzt + Offen + fehlende Pausen
|
||||
const totalRemainingSeconds = remainingSeconds + (missingBreakMinutes.value * 60)
|
||||
const endTimestamp = now + (totalRemainingSeconds * 1000)
|
||||
const endDate = new Date(endTimestamp)
|
||||
const endHours = endDate.getHours()
|
||||
const endMinutes = endDate.getMinutes()
|
||||
const endSeconds = endDate.getSeconds()
|
||||
|
||||
// Zeige auch fehlende Pausen an (falls vorhanden)
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')} (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
} else {
|
||||
openTime.value = 'Arbeitsende erreicht'
|
||||
|
||||
// Auch wenn Arbeitsende erreicht ist, können noch Pausen fehlen
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `Erreicht (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = 'Erreicht'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/clock', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authStore.getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
alert(error.error || 'Fehler beim Stempeln')
|
||||
return
|
||||
}
|
||||
|
||||
// Aktualisiere Status und Worklog-Daten sofort
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Event auslösen für andere Komponenten (z.B. WeekOverview)
|
||||
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stempeln:', error)
|
||||
alert('Fehler beim Stempeln')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Button-Konfiguration basierend auf dem aktuellen Zustand
|
||||
const leftButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return { label: 'Arbeit beginnen', action: 'start work', class: 'btn-success' }
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Arbeit beenden', action: 'stop work', class: 'btn-danger' }
|
||||
case 'start pause':
|
||||
return null // Nicht sichtbar
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const rightButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return null // Nicht sichtbar
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Pause beginnen', action: 'start pause' }
|
||||
case 'start pause':
|
||||
return { label: 'Pause beenden', action: 'stop pause' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Initiales Laden
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Server-Daten alle 60 Sekunden neu laden
|
||||
dataFetchInterval = setInterval(async () => {
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
}, 60000)
|
||||
|
||||
// Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
|
||||
displayUpdateInterval = setInterval(() => {
|
||||
updateCurrentlyWorkedTime()
|
||||
updateOpenTime()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dataFetchInterval) clearInterval(dataFetchInterval)
|
||||
if (displayUpdateInterval) clearInterval(displayUpdateInterval)
|
||||
})
|
||||
|
||||
const displayRows = computed(() => {
|
||||
const rows = {
|
||||
'Derzeit gearbeitet': currentlyWorkedTime.value, // Verwende berechneten Wert
|
||||
'Offen': openTime.value, // Verwende berechneten Wert
|
||||
'Normales Arbeitsende': regularEndTime.value // Verwende berechneten Wert
|
||||
}
|
||||
|
||||
// Füge andere Stats hinzu
|
||||
const map = [
|
||||
['Überstunden (Woche)', 'overtime'],
|
||||
['Überstunden (Gesamt)', 'totalOvertime'],
|
||||
// ['Überstunden (Alt-Style)', 'totalOvertimeOldStyle'], // DEBUG: Versteckt, da getWeekOverview nicht korrekte Zeiten liefert
|
||||
['Wochenarbeitszeit', 'weekWorktime'],
|
||||
['Arbeitsfreie Stunden', 'nonWorkingHours'],
|
||||
['Offen für Woche', 'openForWeek'],
|
||||
['Bereinigtes Arbeitsende (heute)', null], // Nur Überschrift, kein Wert
|
||||
['- Generell', 'adjustedEndTodayGeneral'],
|
||||
['- Woche', 'adjustedEndTodayWeek']
|
||||
]
|
||||
|
||||
for (const [label, key] of map) {
|
||||
// Spezialbehandlung für Überschrift ohne Wert
|
||||
if (key === null) {
|
||||
rows[label] = null // Überschrift ohne Wert (wird im Template speziell behandelt)
|
||||
continue
|
||||
}
|
||||
|
||||
const val = stats.value?.[key]
|
||||
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
rows[label] = val
|
||||
} else {
|
||||
rows[label] = '—'
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-box {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||
position: fixed;
|
||||
top: 58px;
|
||||
right: 24px;
|
||||
max-width: 560px;
|
||||
min-width: 280px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.status-box.loading {
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #f8f8f8;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
color: #155724;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #c3e6cb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #f5c6cb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e7f1ff;
|
||||
border-color: #b8daff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #b8daff;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
font-size: 12px;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row.heading-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row.heading-row .label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user