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:
Torsten Schulz (local)
2025-10-17 14:11:28 +02:00
commit e95bb4cb76
86 changed files with 19530 additions and 0 deletions

View 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>