Verbessere die Handhabung von Datumsbereichen in der Worklog-Abfrage und füge Fehlerbehandlung für Statistikabfragen in den Komponenten hinzu.
All checks were successful
Deploy production / deploy (push) Successful in 42s
All checks were successful
Deploy production / deploy (push) Successful in 42s
This commit is contained in:
@@ -23,6 +23,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="statsError" class="stats-error" role="alert">
|
||||
{{ statsError }}
|
||||
<button class="btn btn-small" @click="refreshStatusData" :disabled="loading">Erneut versuchen</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>
|
||||
@@ -44,6 +48,7 @@ const API_URL = API_BASE_URL
|
||||
const timeStore = useTimeStore()
|
||||
const authStore = useAuthStore()
|
||||
const stats = ref({})
|
||||
const statsError = ref('')
|
||||
const currentState = ref(null) // 'null', 'start work', 'start pause', 'stop pause'
|
||||
const isReady = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -61,36 +66,43 @@ let dataFetchInterval = null // Daten vom Server laden
|
||||
let displayUpdateInterval = null // Anzeige aktualisieren
|
||||
|
||||
const fetchStats = async () => {
|
||||
const data = await timeStore.fetchStats()
|
||||
stats.value = data || {}
|
||||
try {
|
||||
const data = await timeStore.fetchStats()
|
||||
statsError.value = ''
|
||||
stats.value = data || {}
|
||||
|
||||
// Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp
|
||||
if (data?.currentlyWorked) {
|
||||
serverWorkedTime.value = data.currentlyWorked
|
||||
} else {
|
||||
serverWorkedTime.value = null
|
||||
// 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
|
||||
}
|
||||
|
||||
updateCurrentlyWorkedTime()
|
||||
updateOpenTime()
|
||||
} catch (error) {
|
||||
statsError.value = 'Statistiken konnten nicht geladen werden.' +
|
||||
(serverTimestamp.value ? ' Angezeigte Werte können veraltet sein.' : '')
|
||||
} finally {
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
updateCurrentlyWorkedTime()
|
||||
updateOpenTime()
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
/** Lädt Zustand, laufende Zeiten und Tages-Stats neu (z. B. nach Stempeln oder Zeitkorrektur). */
|
||||
@@ -419,8 +431,8 @@ const displayRows = computed(() => {
|
||||
|
||||
// Füge andere Stats hinzu
|
||||
const map = [
|
||||
['overtime', 'overtime'], // Label wird dynamisch gesetzt
|
||||
['totalOvertime', 'totalOvertime'], // Label wird dynamisch gesetzt
|
||||
['Überstunden (Woche)', 'overtime'], // Label wird dynamisch gesetzt
|
||||
['Überstunden (Gesamt)', 'totalOvertime'], // Label wird dynamisch gesetzt
|
||||
['Wochenarbeitszeit', 'weekWorktime'],
|
||||
['Arbeitsfreie Stunden', 'nonWorkingHours'],
|
||||
['Offen für Woche', 'openForWeek'],
|
||||
@@ -554,6 +566,12 @@ const displayRows = computed(() => {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.stats-error {
|
||||
color: #842029;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
font-size: 12px;
|
||||
column-gap: 16px;
|
||||
|
||||
@@ -106,10 +106,13 @@ export const useTimeStore = defineStore('time', () => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries/stats/summary`)
|
||||
return await response.json()
|
||||
if (!response.ok) throw new Error('Statistiken konnten nicht geladen werden')
|
||||
const data = await response.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Statistiken:', error)
|
||||
return {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
<button @click="nextWeek" class="btn btn-secondary">Nächste Woche →</button>
|
||||
</div>
|
||||
|
||||
<table class="week-table">
|
||||
<div v-if="loadError" class="load-error" role="alert">
|
||||
{{ loadError }}
|
||||
<button @click="loadWeekData()" class="btn btn-secondary">Erneut versuchen</button>
|
||||
</div>
|
||||
|
||||
<table v-if="weekData" class="week-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
@@ -202,6 +207,8 @@ const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const weekData = ref(null)
|
||||
const loadError = ref('')
|
||||
let latestRequest = 0
|
||||
const weekOffset = ref(0)
|
||||
let refreshInterval = null
|
||||
|
||||
@@ -216,8 +223,14 @@ const weekDays = computed(() => {
|
||||
})
|
||||
|
||||
const weekRange = computed(() => {
|
||||
if (!weekData.value) return ''
|
||||
|
||||
if (!weekData.value) {
|
||||
const monday = new Date();
|
||||
monday.setDate(monday.getDate() + (monday.getDay() === 0 ? -6 : 1 - monday.getDay()) + weekOffset.value * 7);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
return `${formatDate(monday)} - ${formatDate(sunday)}`;
|
||||
}
|
||||
|
||||
const start = formatDate(weekData.value.weekStart)
|
||||
const end = formatDate(weekData.value.weekEnd)
|
||||
|
||||
@@ -238,10 +251,12 @@ const formatDate = (dateString) => {
|
||||
}
|
||||
|
||||
const loadWeekData = async (background = false) => {
|
||||
const requestId = ++latestRequest
|
||||
const requestedOffset = weekOffset.value
|
||||
try {
|
||||
if (!background) loading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/week-overview?weekOffset=${weekOffset.value}`, {
|
||||
const response = await fetch(`${API_URL}/week-overview?weekOffset=${requestedOffset}`, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`,
|
||||
@@ -255,17 +270,20 @@ const loadWeekData = async (background = false) => {
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
if (requestId !== latestRequest) return
|
||||
if (result.success && result.data?.weekStart && Array.isArray(result.data.days)) {
|
||||
weekData.value = result.data
|
||||
loadError.value = ''
|
||||
} else {
|
||||
throw new Error(result.error || 'Unbekannter Fehler')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Wochenübersicht:', error)
|
||||
// Fallback zu Dummy-Daten bei Fehlern
|
||||
weekData.value = getDummyData()
|
||||
if (requestId !== latestRequest) return
|
||||
weekData.value = null
|
||||
loadError.value = 'Die Wochenübersicht konnte nicht geladen werden.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === latestRequest) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,101 +297,6 @@ const nextWeek = () => {
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
const getDummyData = () => {
|
||||
return {
|
||||
weekStart: '2025-01-13',
|
||||
weekEnd: '2025-01-19',
|
||||
weekOffset: weekOffset.value,
|
||||
weekTotal: '40:00',
|
||||
days: [
|
||||
{
|
||||
date: '2025-01-13',
|
||||
name: 'Montag',
|
||||
workTime: '08:00 - 16:30',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:30',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '8:00',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-14',
|
||||
name: 'Dienstag',
|
||||
workTime: '08:15 - 17:00',
|
||||
pauses: ['12:00 - 12:30', '14:00 - 14:30'],
|
||||
totalWorkTime: '8:45',
|
||||
pauseTimes: ['0:30', '0:30'],
|
||||
netWorkTime: '7:45',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-15',
|
||||
name: 'Mittwoch',
|
||||
workTime: '08:00 - 16:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '7:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-16',
|
||||
name: 'Donnerstag',
|
||||
workTime: '08:30 - 17:30',
|
||||
pauses: ['12:00 - 12:30', '15:00 - 15:15'],
|
||||
totalWorkTime: '9:00',
|
||||
pauseTimes: ['0:30', '0:15'],
|
||||
netWorkTime: '8:15',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-17',
|
||||
name: 'Freitag',
|
||||
workTime: '08:00 - 15:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '7:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '6:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-18',
|
||||
name: 'Samstag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-19',
|
||||
name: 'Sonntag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorklogUpdate = () => {
|
||||
console.log('Worklog aktualisiert, lade Wochendaten neu...')
|
||||
loadWeekData()
|
||||
@@ -418,6 +341,13 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.load-error {
|
||||
padding: 1rem;
|
||||
color: #842029;
|
||||
background: #f8d7da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
107
frontend/test/load-errors.test.cjs
Normal file
107
frontend/test/load-errors.test.cjs
Normal file
@@ -0,0 +1,107 @@
|
||||
process.env.TZ = 'Europe/Berlin';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { parse, compileScript } = require('@vue/compiler-sfc');
|
||||
const { transformSync } = require('esbuild');
|
||||
const vue = require('vue');
|
||||
|
||||
// Execute the real Vue setup and store code, with HTTP responses controlled by
|
||||
// each test. No mounted app or browser globals are needed for these load paths.
|
||||
function load(relativePath, dependencies = {}) {
|
||||
const filename = path.resolve(__dirname, '..', relativePath);
|
||||
let source = readFileSync(filename, 'utf8');
|
||||
if (filename.endsWith('.vue')) {
|
||||
source = compileScript(parse(source).descriptor, { id: 'load-error-test' }).content;
|
||||
}
|
||||
const code = transformSync(source, { format: 'cjs', loader: 'js' }).code;
|
||||
const module = { exports: {} };
|
||||
vm.runInThisContext(`(function(require, module, exports) { ${code}\n})`, { filename })(id => {
|
||||
if (id in dependencies) return dependencies[id];
|
||||
if (id === 'vue') return { ...vue, onMounted() {}, onBeforeUnmount() {} };
|
||||
if (id === 'pinia') return { defineStore: (_name, setup) => setup };
|
||||
if (id === '@/config/api') return { API_BASE_URL: '/api' };
|
||||
if (id.endsWith('authStore')) return {
|
||||
useAuthStore: () => ({ token: 'test-token', getAuthHeaders: () => ({}), clearAuth() {} })
|
||||
};
|
||||
throw new Error(`Unexpected import: ${id}`);
|
||||
}, module, module.exports);
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
function component(file, dependencies) {
|
||||
return load(file, dependencies).default.setup({}, { expose() {} });
|
||||
}
|
||||
|
||||
const ok = data => ({ ok: true, json: async () => data });
|
||||
|
||||
test('Wochenansicht fordert beim Öffnen aktuelle Woche an', async t => {
|
||||
let requestedUrl;
|
||||
t.mock.method(global, 'fetch', async url => {
|
||||
requestedUrl = url;
|
||||
return ok({ success: true, data: { weekStart: '2026-10-26', weekEnd: '2026-11-01', days: [] } });
|
||||
});
|
||||
const page = component('src/views/WeekOverview.vue');
|
||||
await page.loadWeekData();
|
||||
assert.equal(requestedUrl, '/api/week-overview?weekOffset=0');
|
||||
assert.equal(page.weekRange.value, '26.10.2026 - 01.11.2026');
|
||||
assert.equal(page.loadError.value, '');
|
||||
});
|
||||
|
||||
test('Wochenansicht zeigt bei HTTP 500 Fehler und aktuelle Wochengrenzen statt Beispieldaten', async t => {
|
||||
const RealDate = Date;
|
||||
t.mock.method(global, 'Date', class extends RealDate {
|
||||
constructor(...args) { super(...(args.length ? args : ['2026-10-27T14:00:00'])); }
|
||||
});
|
||||
t.mock.method(console, 'error', () => {});
|
||||
t.mock.method(global, 'fetch', async () => ({ ok: false, status: 500 }));
|
||||
const page = component('src/views/WeekOverview.vue');
|
||||
await page.loadWeekData();
|
||||
assert.equal(page.weekData.value, null);
|
||||
assert.deepEqual(page.weekDays.value, []);
|
||||
assert.equal(page.weekRange.value, '26.10.2026 - 01.11.2026');
|
||||
assert.match(page.loadError.value, /nicht geladen/);
|
||||
assert.equal(page.loading.value, false);
|
||||
});
|
||||
|
||||
test('Ältere Wochenantwort überschreibt die zuletzt ausgewählte Woche nicht', async t => {
|
||||
let finishOldRequest;
|
||||
t.mock.method(global, 'fetch', url => url.endsWith('=0')
|
||||
? new Promise(resolve => { finishOldRequest = resolve; })
|
||||
: Promise.resolve(ok({ success: true, data: { weekStart: '2026-11-02', weekEnd: '2026-11-08', days: [] } })));
|
||||
const page = component('src/views/WeekOverview.vue');
|
||||
const oldRequest = page.loadWeekData();
|
||||
page.weekOffset.value = 1;
|
||||
await page.loadWeekData();
|
||||
finishOldRequest(ok({ success: true, data: { weekStart: '2026-10-26', weekEnd: '2026-11-01', days: [] } }));
|
||||
await oldRequest;
|
||||
assert.equal(page.weekRange.value, '02.11.2026 - 08.11.2026');
|
||||
});
|
||||
|
||||
test('Statistikfehler wird angezeigt, gültige Werte bleiben erhalten und Wiederholen funktioniert', async t => {
|
||||
t.mock.method(console, 'error', () => {});
|
||||
const stats = {
|
||||
timestamp: new Date().toISOString(), currentlyWorked: '03:53:53', open: '03:06:07',
|
||||
overtime: '+0:00', totalOvertime: '+5:49', weekWorktime: '3:53', nonWorkingHours: '8:00'
|
||||
};
|
||||
let failing = true;
|
||||
t.mock.method(global, 'fetch', async () => failing ? { ok: false, status: 500 } : ok(stats));
|
||||
const store = load('src/stores/timeStore.js');
|
||||
const box = component('src/components/StatusBox.vue', { '../stores/timeStore': store });
|
||||
await box.fetchStats();
|
||||
assert.match(box.statsError.value, /nicht geladen/);
|
||||
assert.equal(box.isReady.value, true);
|
||||
assert.equal(box.displayRows.value['Überstunden (Woche)'], '—');
|
||||
assert.equal('overtime' in box.displayRows.value, false);
|
||||
assert.equal('totalOvertime' in box.displayRows.value, false);
|
||||
failing = false;
|
||||
await box.fetchStats();
|
||||
assert.equal(box.statsError.value, '');
|
||||
assert.equal(box.displayRows.value['Überstunden (Gesamt)'], '+5:49 h');
|
||||
failing = true;
|
||||
await box.fetchStats();
|
||||
assert.match(box.statsError.value, /veraltet/);
|
||||
assert.equal(box.displayRows.value['Überstunden (Gesamt)'], '+5:49 h');
|
||||
});
|
||||
Reference in New Issue
Block a user