From 6a519fc4d4b97431c916dbce025b2b3df4aa489b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 18 Oct 2025 00:00:34 +0200 Subject: [PATCH] Add export routes to backend and frontend; implement routing and UI components for data export management --- backend/src/controllers/ExportController.js | 91 ++++ backend/src/index.js | 4 + backend/src/routes/export.js | 19 + backend/src/services/ExportService.js | 220 ++++++++++ frontend/src/App.vue | 1 + frontend/src/router/index.js | 7 + frontend/src/views/Export.vue | 454 ++++++++++++++++++++ 7 files changed, 796 insertions(+) create mode 100644 backend/src/controllers/ExportController.js create mode 100644 backend/src/routes/export.js create mode 100644 backend/src/services/ExportService.js create mode 100644 frontend/src/views/Export.vue diff --git a/backend/src/controllers/ExportController.js b/backend/src/controllers/ExportController.js new file mode 100644 index 0000000..e4950f2 --- /dev/null +++ b/backend/src/controllers/ExportController.js @@ -0,0 +1,91 @@ +const ExportService = require('../services/ExportService'); + +/** + * Controller für Daten-Export + * Verarbeitet HTTP-Requests und delegiert an ExportService + */ +class ExportController { + /** + * Exportiert Daten als CSV + */ + async exportCSV(req, res) { + try { + const userId = req.user?.id || 1; + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + message: 'Start- und Enddatum sind erforderlich' + }); + } + + const csv = await ExportService.exportCSV(userId, startDate, endDate); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung_${startDate}_${endDate}.csv"`); + res.send(csv); + } catch (error) { + console.error('Fehler beim CSV-Export:', error); + res.status(500).json({ + message: 'Fehler beim Export', + error: error.message + }); + } + } + + /** + * Exportiert Daten als Excel + */ + async exportExcel(req, res) { + try { + const userId = req.user?.id || 1; + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + message: 'Start- und Enddatum sind erforderlich' + }); + } + + const excel = await ExportService.exportExcel(userId, startDate, endDate); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="zeiterfassung_${startDate}_${endDate}.csv"`); + res.send(excel); + } catch (error) { + console.error('Fehler beim Excel-Export:', error); + res.status(500).json({ + message: 'Fehler beim Export', + error: error.message + }); + } + } + + /** + * Exportiert Daten für PDF (gibt JSON zurück, PDF wird im Frontend generiert) + */ + async exportPDF(req, res) { + try { + const userId = req.user?.id || 1; + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + return res.status(400).json({ + message: 'Start- und Enddatum sind erforderlich' + }); + } + + const data = await ExportService.exportPDFData(userId, startDate, endDate); + res.json(data); + } catch (error) { + console.error('Fehler beim PDF-Export:', error); + res.status(500).json({ + message: 'Fehler beim Export', + error: error.message + }); + } + } +} + +module.exports = new ExportController(); + diff --git a/backend/src/index.js b/backend/src/index.js index a54afa0..a89bc12 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -114,6 +114,10 @@ app.use('/api/invite', authenticateToken, inviteRouter); const watcherRouter = require('./routes/watcher'); app.use('/api/watcher', authenticateToken, watcherRouter); +// Export routes (geschützt) - MIT ID-Hashing +const exportRouter = require('./routes/export'); +app.use('/api/export', authenticateToken, exportRouter); + // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js new file mode 100644 index 0000000..16f0eb0 --- /dev/null +++ b/backend/src/routes/export.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const ExportController = require('../controllers/ExportController'); + +/** + * Routen für Daten-Export + */ + +// GET /api/export/csv?startDate=2025-01-01&endDate=2025-12-31 +router.get('/csv', ExportController.exportCSV.bind(ExportController)); + +// GET /api/export/excel?startDate=2025-01-01&endDate=2025-12-31 +router.get('/excel', ExportController.exportExcel.bind(ExportController)); + +// GET /api/export/pdf?startDate=2025-01-01&endDate=2025-12-31 +router.get('/pdf', ExportController.exportPDF.bind(ExportController)); + +module.exports = router; + diff --git a/backend/src/services/ExportService.js b/backend/src/services/ExportService.js new file mode 100644 index 0000000..6609c2a --- /dev/null +++ b/backend/src/services/ExportService.js @@ -0,0 +1,220 @@ +const database = require('../config/database'); +const { Op } = require('sequelize'); + +/** + * Service-Klasse für Daten-Export + * Exportiert Arbeitszeiten in verschiedenen Formaten + */ +class ExportService { + /** + * Exportiert Daten als CSV + * Format: Datum;Uhrzeit;Aktivität;Gesamt-Netto-Arbeitszeit + * @param {number} userId - User-ID + * @param {string} startDate - Startdatum (YYYY-MM-DD) + * @param {string} endDate - Enddatum (YYYY-MM-DD) + * @returns {Promise} CSV-String + */ + async exportCSV(userId, startDate, endDate) { + const data = await this._getExportData(userId, startDate, endDate); + + // CSV Header + let csv = 'Datum;Uhrzeit;Aktivität;Gesamt-Netto-Arbeitszeit\n'; + + // CSV Zeilen + data.forEach(row => { + csv += `${row.date};${row.time};${row.activity};${row.netWorkTime}\n`; + }); + + return csv; + } + + /** + * Exportiert Daten als Excel (CSV mit Excel-kompatiblem Encoding) + * @param {number} userId - User-ID + * @param {string} startDate - Startdatum + * @param {string} endDate - Enddatum + * @returns {Promise} Excel-kompatibles CSV mit BOM + */ + async exportExcel(userId, startDate, endDate) { + const csv = await this.exportCSV(userId, startDate, endDate); + + // UTF-8 BOM für Excel + return '\ufeff' + csv; + } + + /** + * Bereitet Daten für PDF-Export vor (Wochen-Format) + * @param {number} userId - User-ID + * @param {string} startDate - Startdatum + * @param {string} endDate - Enddatum + * @returns {Promise} Strukturierte Daten für PDF + */ + async exportPDFData(userId, startDate, endDate) { + const TimeEntryService = require('./TimeEntryService'); + + const start = new Date(startDate + 'T00:00:00'); + const end = new Date(endDate + 'T00:00:00'); + + // Berechne Anzahl Wochen + const weeks = []; + let currentDate = new Date(start); + + while (currentDate <= end) { + // Finde Montag der aktuellen Woche + const dayOfWeek = currentDate.getDay(); + const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const weekStart = new Date(currentDate); + weekStart.setDate(weekStart.getDate() + daysToMonday); + + // Berechne Wochen-Offset + const now = new Date(); + const nowDayOfWeek = now.getDay(); + const nowDaysToMonday = nowDayOfWeek === 0 ? -6 : 1 - nowDayOfWeek; + const thisWeekMonday = new Date(now); + thisWeekMonday.setDate(thisWeekMonday.getDate() + nowDaysToMonday); + thisWeekMonday.setHours(0, 0, 0, 0); + weekStart.setHours(0, 0, 0, 0); + + const weekOffset = Math.round((weekStart - thisWeekMonday) / (7 * 24 * 60 * 60 * 1000)); + + // Hole Wochendaten + const weekData = await TimeEntryService.getWeekOverview(userId, weekOffset); + weeks.push(weekData); + + // Nächste Woche + currentDate.setDate(currentDate.getDate() + 7); + } + + return { + startDate, + endDate, + weeks + }; + } + + /** + * Holt Export-Daten (für CSV/Excel) + * @private + */ + async _getExportData(userId, startDate, endDate) { + const { Worklog, Timefix } = database.getModels(); + const sequelize = database.sequelize; + + // Hole alle Worklog-Einträge mit Timefix-Korrekturen + const query = ` + SELECT + w.id, + DATE(w.tstamp) as date, + w.tstamp, + w.state, + w.relatedTo_id, + COALESCE(tf.fix_date_time, w.tstamp) as corrected_time, + COALESCE(tf.fix_type, + CASE + WHEN w.state LIKE '%start work%' THEN 'start work' + WHEN w.state LIKE '%stop work%' THEN 'stop work' + WHEN w.state LIKE '%start pause%' THEN 'start pause' + WHEN w.state LIKE '%stop pause%' THEN 'stop pause' + ELSE w.state + END + ) as corrected_action + FROM worklog w + LEFT JOIN timefix tf ON tf.worklog_id = w.id + WHERE w.user_id = :userId + AND DATE(w.tstamp) BETWEEN :startDate AND :endDate + ORDER BY w.tstamp ASC + `; + + const entries = await sequelize.query(query, { + replacements: { userId, startDate, endDate }, + type: sequelize.QueryTypes.SELECT + }); + + // Gruppiere nach Datum und berechne Nettoarbeitszeit + const dayMap = new Map(); + + entries.forEach(entry => { + if (!dayMap.has(entry.date)) { + dayMap.set(entry.date, { + entries: [], + netMinutes: 0 + }); + } + dayMap.get(entry.date).entries.push(entry); + }); + + // Berechne für jeden Tag + const result = []; + + dayMap.forEach((dayData, date) => { + const dayEntries = dayData.entries; + let netMinutes = 0; + + // Finde alle start work -> stop work Paare + dayEntries.forEach(entry => { + const action = entry.corrected_action; + + if (action === 'stop work') { + const startEntry = dayEntries.find(e => e.id === entry.relatedTo_id); + + if (startEntry) { + const start = new Date(startEntry.corrected_time); + const end = new Date(entry.corrected_time); + const workMinutes = (end - start) / (1000 * 60); + + // Subtrahiere Pausen + const pauses = dayEntries.filter(e => + new Date(e.corrected_time) > start && + new Date(e.corrected_time) < end && + (e.corrected_action === 'start pause' || e.corrected_action === 'stop pause') + ); + + let pauseMinutes = 0; + pauses.forEach(pauseEntry => { + if (pauseEntry.corrected_action === 'stop pause') { + const pauseStart = pauses.find(p => p.id === pauseEntry.relatedTo_id); + if (pauseStart) { + const pStart = new Date(pauseStart.corrected_time); + const pEnd = new Date(pauseEntry.corrected_time); + pauseMinutes += (pEnd - pStart) / (1000 * 60); + } + } + }); + + netMinutes += workMinutes - pauseMinutes; + } + } + }); + + // Formatiere Netto-Arbeitszeit + const netHours = Math.floor(netMinutes / 60); + const netMins = Math.round(netMinutes % 60); + const netTimeFormatted = `${netHours}:${netMins.toString().padStart(2, '0')}`; + + // Erstelle Zeilen für alle Einträge dieses Tages + dayEntries.forEach(entry => { + const time = new Date(entry.corrected_time); + const timeStr = time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + + const activityLabels = { + 'start work': 'Arbeit gestartet', + 'stop work': 'Arbeit beendet', + 'start pause': 'Pause gestartet', + 'stop pause': 'Pause beendet' + }; + + result.push({ + date: new Date(date + 'T00:00:00').toLocaleDateString('de-DE'), + time: timeStr, + activity: activityLabels[entry.corrected_action] || entry.corrected_action, + netWorkTime: entry.corrected_action === 'stop work' ? netTimeFormatted : '' + }); + }); + }); + + return result; + } +} + +module.exports = new ExportService(); + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index be117bd..9255a55 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -79,6 +79,7 @@ const pageTitle = computed(() => { 'settings-timewish': 'Zeitwünsche', 'settings-invite': 'Einladen', 'settings-permissions': 'Berechtigungen', + 'export': 'Export', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 873f1aa..7a94074 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -22,6 +22,7 @@ import Timewish from '../views/Timewish.vue' import Roles from '../views/Roles.vue' import Invite from '../views/Invite.vue' import Permissions from '../views/Permissions.vue' +import Export from '../views/Export.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -140,6 +141,12 @@ const router = createRouter({ component: Permissions, meta: { requiresAuth: true } }, + { + path: '/export', + name: 'export', + component: Export, + meta: { requiresAuth: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Export.vue b/frontend/src/views/Export.vue new file mode 100644 index 0000000..f5e452f --- /dev/null +++ b/frontend/src/views/Export.vue @@ -0,0 +1,454 @@ + + + + + +