Add timefix and vacation routes to backend; update frontend for new routes and page titles
This commit is contained in:
9
backend/add-vacation-index.sql
Normal file
9
backend/add-vacation-index.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Füge Index für vacation Tabelle hinzu
|
||||||
|
-- Optimiert die Abfrage: WHERE user_id = ? AND first_day >= ? ORDER BY first_day DESC
|
||||||
|
|
||||||
|
CREATE INDEX idx_vacation_user_first_day
|
||||||
|
ON vacation (user_id, first_day DESC);
|
||||||
|
|
||||||
|
-- Prüfe ob der Index erstellt wurde
|
||||||
|
SHOW INDEX FROM vacation WHERE Key_name = 'idx_vacation_user_first_day';
|
||||||
|
|
||||||
94
backend/src/controllers/TimefixController.js
Normal file
94
backend/src/controllers/TimefixController.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
const timefixService = require('../services/TimefixService');
|
||||||
|
|
||||||
|
class TimefixController {
|
||||||
|
/**
|
||||||
|
* Holt alle Worklog-Einträge für ein bestimmtes Datum
|
||||||
|
*/
|
||||||
|
async getWorklogEntriesForDate(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { date } = req.query;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ message: 'Datum ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await timefixService.getWorklogEntriesForDate(userId, date);
|
||||||
|
res.json(entries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Worklog-Einträge:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Abrufen der Worklog-Einträge',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt alle Zeitkorrekturen für den heutigen Tag
|
||||||
|
*/
|
||||||
|
async getTodayTimefixes(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const timefixes = await timefixService.getTodayTimefixes(userId);
|
||||||
|
res.json(timefixes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Timefixes:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Abrufen der Zeitkorrekturen',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Zeitkorrektur
|
||||||
|
*/
|
||||||
|
async createTimefix(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { worklogId, newDate, newTime, newAction } = req.body;
|
||||||
|
|
||||||
|
if (!worklogId || !newDate || !newTime || !newAction) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Fehlende Pflichtfelder: worklogId, newDate, newTime, newAction'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timefix = await timefixService.createTimefix(userId, worklogId, newDate, newTime, newAction);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Zeitkorrektur erfolgreich erstellt',
|
||||||
|
timefix
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen der Timefix:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Erstellen der Zeitkorrektur',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Zeitkorrektur
|
||||||
|
*/
|
||||||
|
async deleteTimefix(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const timefixId = req.params.id;
|
||||||
|
|
||||||
|
await timefixService.deleteTimefix(userId, timefixId);
|
||||||
|
|
||||||
|
res.json({ message: 'Zeitkorrektur erfolgreich gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen der Timefix:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Löschen der Zeitkorrektur',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TimefixController();
|
||||||
73
backend/src/controllers/VacationController.js
Normal file
73
backend/src/controllers/VacationController.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const vacationService = require('../services/VacationService');
|
||||||
|
|
||||||
|
class VacationController {
|
||||||
|
/**
|
||||||
|
* Holt alle Urlaubseinträge für einen User
|
||||||
|
*/
|
||||||
|
async getAllVacations(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const vacations = await vacationService.getAllVacations(userId);
|
||||||
|
res.json(vacations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Urlaubseinträge:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Abrufen der Urlaubseinträge',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Urlaubseintrag
|
||||||
|
*/
|
||||||
|
async createVacation(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { vacationType, startDate, endDate } = req.body;
|
||||||
|
|
||||||
|
if (vacationType === undefined || !startDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Fehlende Pflichtfelder: vacationType, startDate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const vacation = await vacationService.createVacation(userId, vacationType, startDate, endDate);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Urlaubseintrag erfolgreich erstellt',
|
||||||
|
vacation
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen des Urlaubseintrags:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Erstellen des Urlaubseintrags',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Urlaubseintrag
|
||||||
|
*/
|
||||||
|
async deleteVacation(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const vacationId = req.params.id;
|
||||||
|
|
||||||
|
console.log('DEBUG deleteVacation: vacationId =', vacationId, 'type =', typeof vacationId);
|
||||||
|
|
||||||
|
await vacationService.deleteVacation(userId, vacationId);
|
||||||
|
|
||||||
|
res.json({ message: 'Urlaubseintrag erfolgreich gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen des Urlaubseintrags:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Löschen des Urlaubseintrags',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new VacationController();
|
||||||
@@ -66,6 +66,14 @@ app.use('/api/time-entries', authenticateToken, timeEntriesRouter);
|
|||||||
const weekOverviewRouter = require('./routes/weekOverview');
|
const weekOverviewRouter = require('./routes/weekOverview');
|
||||||
app.use('/api/week-overview', authenticateToken, weekOverviewRouter);
|
app.use('/api/week-overview', authenticateToken, weekOverviewRouter);
|
||||||
|
|
||||||
|
// Timefix routes (geschützt) - MIT ID-Hashing
|
||||||
|
const timefixRouter = require('./routes/timefix');
|
||||||
|
app.use('/api/timefix', authenticateToken, timefixRouter);
|
||||||
|
|
||||||
|
// Vacation routes (geschützt) - MIT ID-Hashing
|
||||||
|
const vacationRouter = require('./routes/vacation');
|
||||||
|
app.use('/api/vacation', authenticateToken, vacationRouter);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const unhashRequestIds = (req, res, next) => {
|
|||||||
|
|
||||||
// Route-Parameter verarbeiten
|
// Route-Parameter verarbeiten
|
||||||
if (req.params && typeof req.params === 'object') {
|
if (req.params && typeof req.params === 'object') {
|
||||||
|
console.log('DEBUG unhashRequest: req.params before =', req.params);
|
||||||
req.params = unhashRequestData(req.params);
|
req.params = unhashRequestData(req.params);
|
||||||
|
console.log('DEBUG unhashRequest: req.params after =', req.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
21
backend/src/routes/timefix.js
Normal file
21
backend/src/routes/timefix.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const TimefixController = require('../controllers/TimefixController');
|
||||||
|
const unhashRequestIds = require('../middleware/unhashRequest');
|
||||||
|
|
||||||
|
// Hinweis: Authentifizierung wird bereits in index.js durch authenticateToken Middleware gehandhabt
|
||||||
|
|
||||||
|
// GET /api/timefix/worklog-entries - Worklog-Einträge für ein Datum
|
||||||
|
router.get('/worklog-entries', TimefixController.getWorklogEntriesForDate);
|
||||||
|
|
||||||
|
// GET /api/timefix - Liste aller Timefixes für heute
|
||||||
|
router.get('/', TimefixController.getTodayTimefixes);
|
||||||
|
|
||||||
|
// POST /api/timefix - Neue Zeitkorrektur erstellen
|
||||||
|
router.post('/', TimefixController.createTimefix);
|
||||||
|
|
||||||
|
// DELETE /api/timefix/:id - Zeitkorrektur löschen
|
||||||
|
router.delete('/:id', unhashRequestIds, TimefixController.deleteTimefix);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
18
backend/src/routes/vacation.js
Normal file
18
backend/src/routes/vacation.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const VacationController = require('../controllers/VacationController');
|
||||||
|
const unhashRequestIds = require('../middleware/unhashRequest');
|
||||||
|
|
||||||
|
// Hinweis: Authentifizierung wird bereits in index.js durch authenticateToken Middleware gehandhabt
|
||||||
|
|
||||||
|
// GET /api/vacation - Liste aller Urlaubseinträge
|
||||||
|
router.get('/', VacationController.getAllVacations);
|
||||||
|
|
||||||
|
// POST /api/vacation - Neuen Urlaubseintrag erstellen
|
||||||
|
router.post('/', VacationController.createVacation);
|
||||||
|
|
||||||
|
// DELETE /api/vacation/:id - Urlaubseintrag löschen
|
||||||
|
router.delete('/:id', unhashRequestIds, VacationController.deleteVacation);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
330
backend/src/services/TimefixService.js
Normal file
330
backend/src/services/TimefixService.js
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
const database = require('../config/database');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-Klasse für Zeitkorrekturen
|
||||||
|
* Enthält die gesamte Business-Logik für Timefixes
|
||||||
|
*/
|
||||||
|
class TimefixService {
|
||||||
|
constructor() {
|
||||||
|
this.defaultUserId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt alle Worklog-Einträge für ein bestimmtes Datum
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {string} date - Datum im Format YYYY-MM-DD
|
||||||
|
* @returns {Promise<Array>} Array von Worklog-Einträgen mit korrigierten Zeiten
|
||||||
|
*/
|
||||||
|
async getWorklogEntriesForDate(userId, date) {
|
||||||
|
const { Worklog } = database.getModels();
|
||||||
|
const sequelize = database.sequelize;
|
||||||
|
|
||||||
|
// Hole alle Worklog-Einträge für das Datum
|
||||||
|
const entries = await Worklog.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
tstamp: {
|
||||||
|
[Op.like]: `${date}%`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: [['tstamp', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hole auch die Timefixes für diese Einträge
|
||||||
|
const entryIds = entries.map(e => e.id);
|
||||||
|
const timefixesForEntries = await sequelize.query(
|
||||||
|
`SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`,
|
||||||
|
{
|
||||||
|
replacements: [entryIds],
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Erstelle eine Map: worklog_id -> Timefix
|
||||||
|
const timefixMap = new Map();
|
||||||
|
timefixesForEntries.forEach(tf => {
|
||||||
|
if (!timefixMap.has(tf.worklog_id)) {
|
||||||
|
timefixMap.set(tf.worklog_id, []);
|
||||||
|
}
|
||||||
|
timefixMap.get(tf.worklog_id).push(tf);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatiere die Einträge für das Frontend
|
||||||
|
const formattedEntries = entries.map(entry => {
|
||||||
|
// Prüfe ob es eine Korrektur gibt
|
||||||
|
const timefixForEntry = timefixMap.get(entry.id);
|
||||||
|
|
||||||
|
let hours, minutes, action;
|
||||||
|
|
||||||
|
if (timefixForEntry && timefixForEntry.length > 0) {
|
||||||
|
// Verwende korrigierte Werte aus Timefix
|
||||||
|
const tf = timefixForEntry[0];
|
||||||
|
|
||||||
|
if (typeof tf.fix_date_time === 'string') {
|
||||||
|
const [datePart, timePart] = tf.fix_date_time.split(' ');
|
||||||
|
[hours, minutes] = timePart.split(':').map(Number);
|
||||||
|
} else if (tf.fix_date_time instanceof Date) {
|
||||||
|
hours = tf.fix_date_time.getHours();
|
||||||
|
minutes = tf.fix_date_time.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
action = tf.fix_type;
|
||||||
|
} else {
|
||||||
|
// Keine Korrektur - verwende Original-Werte aus Worklog
|
||||||
|
if (typeof entry.tstamp === 'string') {
|
||||||
|
const [datePart, timePart] = entry.tstamp.split(' ');
|
||||||
|
[hours, minutes] = timePart.split(':').map(Number);
|
||||||
|
} else if (entry.tstamp instanceof Date) {
|
||||||
|
hours = entry.tstamp.getUTCHours();
|
||||||
|
minutes = entry.tstamp.getUTCMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse state
|
||||||
|
action = entry.state;
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(action);
|
||||||
|
action = parsed.action || action;
|
||||||
|
} catch (e) {
|
||||||
|
// action bleibt als String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
time: `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`,
|
||||||
|
action: action,
|
||||||
|
tstamp: entry.tstamp
|
||||||
|
};
|
||||||
|
}).filter(e => e !== null);
|
||||||
|
|
||||||
|
return formattedEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt alle Zeitkorrekturen für den heutigen Tag
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @returns {Promise<Array>} Array von Zeitkorrekturen
|
||||||
|
*/
|
||||||
|
async getTodayTimefixes(userId) {
|
||||||
|
const sequelize = database.sequelize;
|
||||||
|
|
||||||
|
// Berechne Start und Ende des heutigen Tages (lokale Zeit)
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||||
|
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
|
||||||
|
|
||||||
|
// Hole alle Timefixes für heute mit Raw SQL
|
||||||
|
const todayStartStr = `${todayStart.getFullYear()}-${String(todayStart.getMonth() + 1).padStart(2, '0')}-${String(todayStart.getDate()).padStart(2, '0')} 00:00:00`;
|
||||||
|
const todayEndStr = `${todayEnd.getFullYear()}-${String(todayEnd.getMonth() + 1).padStart(2, '0')}-${String(todayEnd.getDate()).padStart(2, '0')} 23:59:59`;
|
||||||
|
|
||||||
|
const timefixes = await sequelize.query(
|
||||||
|
`SELECT id, user_id, worklog_id, fix_type, fix_date_time
|
||||||
|
FROM timefix
|
||||||
|
WHERE user_id = ? AND fix_date_time BETWEEN ? AND ?
|
||||||
|
ORDER BY fix_date_time DESC`,
|
||||||
|
{
|
||||||
|
replacements: [userId, todayStartStr, todayEndStr],
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hole die zugehörigen Worklog-Einträge separat mit Raw SQL
|
||||||
|
const worklogIds = timefixes.map(tf => tf.worklog_id);
|
||||||
|
|
||||||
|
if (worklogIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const worklogs = await sequelize.query(
|
||||||
|
`SELECT id, tstamp, state, relatedTo_id FROM worklog WHERE id IN (?)`,
|
||||||
|
{
|
||||||
|
replacements: [worklogIds],
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Erstelle eine Map für schnellen Zugriff
|
||||||
|
const worklogMap = new Map();
|
||||||
|
worklogs.forEach(wl => {
|
||||||
|
worklogMap.set(wl.id, wl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatiere die Daten für das Frontend
|
||||||
|
const formattedTimefixes = timefixes.map(tf => {
|
||||||
|
const worklog = worklogMap.get(tf.worklog_id);
|
||||||
|
|
||||||
|
if (!worklog || !worklog.tstamp) {
|
||||||
|
console.error('Worklog oder tstamp fehlt für Timefix:', tf.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KORRIGIERTE Werte aus timefix.fix_date_time
|
||||||
|
let newDate, newHours, newMinutes;
|
||||||
|
|
||||||
|
if (typeof tf.fix_date_time === 'string') {
|
||||||
|
const [datePart, timePart] = tf.fix_date_time.split(' ');
|
||||||
|
newDate = datePart;
|
||||||
|
[newHours, newMinutes] = timePart.split(':').map(Number);
|
||||||
|
} else if (tf.fix_date_time instanceof Date) {
|
||||||
|
const d = tf.fix_date_time;
|
||||||
|
newDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
newHours = d.getHours();
|
||||||
|
newMinutes = d.getMinutes();
|
||||||
|
} else {
|
||||||
|
console.error('Unbekannter fix_date_time-Typ:', typeof tf.fix_date_time);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORIGINAL Werte aus worklog
|
||||||
|
let originalDate, originalHours, originalMinutes;
|
||||||
|
|
||||||
|
if (typeof worklog.tstamp === 'string') {
|
||||||
|
const [datePart, timePart] = worklog.tstamp.split(' ');
|
||||||
|
originalDate = datePart;
|
||||||
|
[originalHours, originalMinutes] = timePart.split(':').map(Number);
|
||||||
|
} else if (worklog.tstamp instanceof Date) {
|
||||||
|
const d = worklog.tstamp;
|
||||||
|
originalDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
originalHours = d.getHours();
|
||||||
|
originalMinutes = d.getMinutes();
|
||||||
|
} else {
|
||||||
|
console.error('Unbekannter tstamp-Typ:', typeof worklog.tstamp);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse original action aus worklog.state
|
||||||
|
let originalAction = worklog.state;
|
||||||
|
if (typeof originalAction === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(originalAction);
|
||||||
|
originalAction = parsed.action || originalAction;
|
||||||
|
} catch (e) {
|
||||||
|
// action bleibt als String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tf.id,
|
||||||
|
worklogId: worklog.id,
|
||||||
|
fixType: tf.fix_type,
|
||||||
|
fixDateTime: tf.fix_date_time,
|
||||||
|
// Neue Werte (KORRIGIERT - aus timefix.fix_date_time)
|
||||||
|
newDate: newDate,
|
||||||
|
newTime: `${newHours.toString().padStart(2, '0')}:${newMinutes.toString().padStart(2, '0')}`,
|
||||||
|
newAction: tf.fix_type,
|
||||||
|
// Original-Werte (aus worklog)
|
||||||
|
originalDate: originalDate,
|
||||||
|
originalTime: `${originalHours.toString().padStart(2, '0')}:${originalMinutes.toString().padStart(2, '0')}`,
|
||||||
|
originalAction: originalAction
|
||||||
|
};
|
||||||
|
}).filter(tf => tf !== null);
|
||||||
|
|
||||||
|
return formattedTimefixes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine neue Zeitkorrektur
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {number} worklogId - Worklog-Eintrag-ID
|
||||||
|
* @param {string} newDate - Neues Datum (YYYY-MM-DD)
|
||||||
|
* @param {string} newTime - Neue Uhrzeit (HH:MM)
|
||||||
|
* @param {string} newAction - Neue Aktion
|
||||||
|
* @returns {Promise<Object>} Erstellter Timefix
|
||||||
|
*/
|
||||||
|
async createTimefix(userId, worklogId, newDate, newTime, newAction) {
|
||||||
|
const { Timefix } = database.getModels();
|
||||||
|
const sequelize = database.sequelize;
|
||||||
|
|
||||||
|
// Hole den Original-Worklog-Eintrag mit Raw SQL
|
||||||
|
const worklogResult = await sequelize.query(
|
||||||
|
`SELECT id, user_id, tstamp, state FROM worklog WHERE id = ?`,
|
||||||
|
{
|
||||||
|
replacements: [worklogId],
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!worklogResult || worklogResult.length === 0) {
|
||||||
|
throw new Error('Worklog-Eintrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const worklog = worklogResult[0];
|
||||||
|
|
||||||
|
// Prüfe Berechtigung
|
||||||
|
if (worklog.user_id !== userId) {
|
||||||
|
throw new Error('Keine Berechtigung für diesen Eintrag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle die korrigierte Zeit als Date-Objekt
|
||||||
|
const [newHours, newMinutes] = newTime.split(':').map(Number);
|
||||||
|
const [newYear, newMonth, newDay] = newDate.split('-').map(Number);
|
||||||
|
const correctedDateTime = new Date(newYear, newMonth - 1, newDay, newHours, newMinutes, 0);
|
||||||
|
|
||||||
|
// Prüfe ob bereits ein Timefix existiert
|
||||||
|
const existingTimefix = await Timefix.findOne({
|
||||||
|
where: {
|
||||||
|
worklog_id: worklogId,
|
||||||
|
fix_type: newAction
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTimefix) {
|
||||||
|
// Update existierender Timefix
|
||||||
|
await existingTimefix.update({
|
||||||
|
fix_date_time: correctedDateTime
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: existingTimefix.id,
|
||||||
|
worklogId: existingTimefix.worklog_id,
|
||||||
|
fixType: existingTimefix.fix_type,
|
||||||
|
fixDateTime: existingTimefix.fix_date_time
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle neuen Timefix
|
||||||
|
const timefix = await Timefix.create({
|
||||||
|
user_id: userId,
|
||||||
|
worklog_id: worklogId,
|
||||||
|
fix_type: newAction,
|
||||||
|
fix_date_time: correctedDateTime
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: timefix.id,
|
||||||
|
worklogId: timefix.worklog_id,
|
||||||
|
fixType: timefix.fix_type,
|
||||||
|
fixDateTime: timefix.fix_date_time
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Zeitkorrektur
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {number} timefixId - Timefix-ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteTimefix(userId, timefixId) {
|
||||||
|
const { Timefix } = database.getModels();
|
||||||
|
|
||||||
|
const timefix = await Timefix.findByPk(timefixId);
|
||||||
|
|
||||||
|
if (!timefix) {
|
||||||
|
throw new Error('Zeitkorrektur nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Berechtigung
|
||||||
|
if (timefix.user_id !== userId) {
|
||||||
|
throw new Error('Keine Berechtigung für diese Zeitkorrektur');
|
||||||
|
}
|
||||||
|
|
||||||
|
await timefix.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TimefixService();
|
||||||
|
|
||||||
120
backend/src/services/VacationService.js
Normal file
120
backend/src/services/VacationService.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
const database = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-Klasse für Urlaubseinträge
|
||||||
|
* Enthält die gesamte Business-Logik für Vacation
|
||||||
|
*/
|
||||||
|
class VacationService {
|
||||||
|
constructor() {
|
||||||
|
this.defaultUserId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt alle Urlaubseinträge für einen User
|
||||||
|
* Zeigt nur aktuelles Jahr oder letzte 3 Monate (falls wir in den ersten 3 Monaten sind)
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @returns {Promise<Array>} Array von Urlaubseinträgen
|
||||||
|
*/
|
||||||
|
async getAllVacations(userId) {
|
||||||
|
const { Vacation } = database.getModels();
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth(); // 0-11
|
||||||
|
|
||||||
|
let startDate;
|
||||||
|
|
||||||
|
if (currentMonth < 3) {
|
||||||
|
// Erste 3 Monate des Jahres: Zeige letzte 3 Monate
|
||||||
|
const threeMonthsAgo = new Date(now);
|
||||||
|
threeMonthsAgo.setMonth(now.getMonth() - 3);
|
||||||
|
startDate = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
} else {
|
||||||
|
// Rest des Jahres: Zeige nur aktuelles Jahr
|
||||||
|
startDate = `${currentYear}-01-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vacations = await Vacation.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
first_day: {
|
||||||
|
[Op.gte]: startDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order: [['first_day', 'DESC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatiere die Daten
|
||||||
|
return vacations.map(vac => ({
|
||||||
|
id: vac.id,
|
||||||
|
type: vac.vacation_type === 1 ? 'Halber Tag' : 'Zeitraum',
|
||||||
|
typeValue: vac.vacation_type,
|
||||||
|
startDate: vac.first_day,
|
||||||
|
endDate: vac.last_day
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen neuen Urlaubseintrag
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {number} vacationType - Typ (0 = Zeitraum, 1 = Halber Tag)
|
||||||
|
* @param {string} startDate - Startdatum (YYYY-MM-DD)
|
||||||
|
* @param {string} endDate - Enddatum (YYYY-MM-DD)
|
||||||
|
* @returns {Promise<Object>} Erstellter Urlaubseintrag
|
||||||
|
*/
|
||||||
|
async createVacation(userId, vacationType, startDate, endDate) {
|
||||||
|
const { Vacation } = database.getModels();
|
||||||
|
|
||||||
|
// Bei "Halber Tag" muss startDate = endDate sein
|
||||||
|
let finalEndDate = endDate;
|
||||||
|
if (vacationType === 1) {
|
||||||
|
finalEndDate = startDate;
|
||||||
|
} else if (!endDate) {
|
||||||
|
throw new Error('Urlaubsende ist erforderlich für Zeitraum');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vacation = await Vacation.create({
|
||||||
|
user_id: userId,
|
||||||
|
first_day: startDate,
|
||||||
|
last_day: finalEndDate,
|
||||||
|
vacation_type: vacationType,
|
||||||
|
version: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: vacation.id,
|
||||||
|
type: vacationType === 1 ? 'Halber Tag' : 'Zeitraum',
|
||||||
|
typeValue: vacation.vacation_type,
|
||||||
|
startDate: vacation.first_day,
|
||||||
|
endDate: vacation.last_day
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Urlaubseintrag
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {number} vacationId - Vacation-ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteVacation(userId, vacationId) {
|
||||||
|
const { Vacation } = database.getModels();
|
||||||
|
|
||||||
|
const vacation = await Vacation.findByPk(vacationId);
|
||||||
|
|
||||||
|
if (!vacation) {
|
||||||
|
throw new Error('Urlaubseintrag nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Berechtigung
|
||||||
|
if (vacation.user_id !== userId) {
|
||||||
|
throw new Error('Keine Berechtigung für diesen Eintrag');
|
||||||
|
}
|
||||||
|
|
||||||
|
await vacation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new VacationService();
|
||||||
|
|
||||||
@@ -6,8 +6,12 @@
|
|||||||
<h1 class="brand">
|
<h1 class="brand">
|
||||||
<RouterLink to="/">Stechuhr</RouterLink>
|
<RouterLink to="/">Stechuhr</RouterLink>
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="nav-title-menu">
|
||||||
|
<h2 class="page-title" v-if="pageTitle">{{ pageTitle }}</h2>
|
||||||
<div class="nav-collapse">
|
<div class="nav-collapse">
|
||||||
<SideMenu />
|
<SideMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="pull-right navbar-nav nav">
|
<ul class="pull-right navbar-nav nav">
|
||||||
<li class="user-info">
|
<li class="user-info">
|
||||||
<span class="user-name">{{ authStore.user?.full_name }}</span>
|
<span class="user-name">{{ authStore.user?.full_name }}</span>
|
||||||
@@ -19,7 +23,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status-Bar unterhalb der Titelzeile -->
|
<!-- Status-Bar unterhalb der Titelzeile -->
|
||||||
<div v-if="authStore.isAuthenticated" class="status-bar">
|
<div v-if="authStore.isAuthenticated" class="status-bar">
|
||||||
@@ -44,19 +47,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/authStore'
|
import { useAuthStore } from './stores/authStore'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed } from 'vue'
|
||||||
import StatusBox from './components/StatusBox.vue'
|
import StatusBox from './components/StatusBox.vue'
|
||||||
import SideMenu from './components/SideMenu.vue'
|
import SideMenu from './components/SideMenu.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seitentitel basierend auf der aktuellen Route
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const titles = {
|
||||||
|
'week-overview': 'Wochenübersicht',
|
||||||
|
'timefix': 'Zeitkorrekturen',
|
||||||
|
'vacation': 'Urlaub',
|
||||||
|
'entries': 'Einträge',
|
||||||
|
'stats': 'Statistiken'
|
||||||
|
}
|
||||||
|
return titles[route.name] || ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -77,6 +94,21 @@ const handleLogout = async () => {
|
|||||||
border-bottom: 1px solid #e0ffe0;
|
border-bottom: 1px solid #e0ffe0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-title-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -84,6 +116,7 @@ const handleLogout = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
|
|||||||
216
frontend/src/components/Modal.vue
Normal file
216
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-overlay" @click.self="onCancel">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ title }}</h3>
|
||||||
|
<button class="modal-close" @click="onCancel" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
v-if="type === 'confirm'"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="onCancel"
|
||||||
|
>
|
||||||
|
{{ cancelText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="type === 'confirm' ? 'btn-danger' : 'btn-primary'"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ confirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Hinweis'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'alert', // 'alert' oder 'confirm'
|
||||||
|
validator: (value) => ['alert', 'confirm'].includes(value)
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
type: String,
|
||||||
|
default: 'OK'
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Abbrechen'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
emit('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(135deg, #f0ffec, #e8f5e0);
|
||||||
|
border-bottom: 2px solid #d0f0c0;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c5e1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ecf0f1;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d5dbdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active .modal-container,
|
||||||
|
.modal-leave-active .modal-container {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from .modal-container,
|
||||||
|
.modal-leave-to .modal-container {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
67
frontend/src/composables/useModal.js
Normal file
67
frontend/src/composables/useModal.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useModal() {
|
||||||
|
const showModal = ref(false)
|
||||||
|
const modalConfig = ref({
|
||||||
|
title: 'Hinweis',
|
||||||
|
message: '',
|
||||||
|
type: 'alert',
|
||||||
|
confirmText: 'OK',
|
||||||
|
cancelText: 'Abbrechen'
|
||||||
|
})
|
||||||
|
const resolvePromise = ref(null)
|
||||||
|
|
||||||
|
const alert = (message, title = 'Hinweis') => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
modalConfig.value = {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type: 'alert',
|
||||||
|
confirmText: 'OK',
|
||||||
|
cancelText: 'Abbrechen'
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
resolvePromise.value = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = (message, title = 'Bestätigung') => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
modalConfig.value = {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type: 'confirm',
|
||||||
|
confirmText: 'Ja',
|
||||||
|
cancelText: 'Abbrechen'
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
resolvePromise.value = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
showModal.value = false
|
||||||
|
if (resolvePromise.value) {
|
||||||
|
resolvePromise.value(true)
|
||||||
|
resolvePromise.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
showModal.value = false
|
||||||
|
if (resolvePromise.value) {
|
||||||
|
resolvePromise.value(false)
|
||||||
|
resolvePromise.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showModal,
|
||||||
|
modalConfig,
|
||||||
|
alert,
|
||||||
|
confirm,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import PasswordForgot from '../views/PasswordForgot.vue'
|
|||||||
import PasswordReset from '../views/PasswordReset.vue'
|
import PasswordReset from '../views/PasswordReset.vue'
|
||||||
import OAuthCallback from '../views/OAuthCallback.vue'
|
import OAuthCallback from '../views/OAuthCallback.vue'
|
||||||
import WeekOverview from '../views/WeekOverview.vue'
|
import WeekOverview from '../views/WeekOverview.vue'
|
||||||
|
import Timefix from '../views/Timefix.vue'
|
||||||
|
import Vacation from '../views/Vacation.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -56,6 +58,18 @@ const router = createRouter({
|
|||||||
component: WeekOverview,
|
component: WeekOverview,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/bookings/timefix',
|
||||||
|
name: 'timefix',
|
||||||
|
component: Timefix,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/bookings/vacation',
|
||||||
|
name: 'vacation',
|
||||||
|
component: Vacation,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/entries',
|
path: '/entries',
|
||||||
name: 'entries',
|
name: 'entries',
|
||||||
|
|||||||
513
frontend/src/views/Timefix.vue
Normal file
513
frontend/src/views/Timefix.vue
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timefix-container">
|
||||||
|
<Modal
|
||||||
|
:show="showModal"
|
||||||
|
:title="modalConfig.title"
|
||||||
|
:message="modalConfig.message"
|
||||||
|
:type="modalConfig.type"
|
||||||
|
:confirmText="modalConfig.confirmText"
|
||||||
|
:cancelText="modalConfig.cancelText"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
/>
|
||||||
|
<!-- Formular für neue Zeitkorrektur -->
|
||||||
|
<div class="card form-card">
|
||||||
|
<h2>Neue Zeitkorrektur</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="createTimefix">
|
||||||
|
<!-- Erste Zeile: Original-Eintrag -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="originalDate">Datum des Original-Eintrags</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="originalDate"
|
||||||
|
v-model="form.originalDate"
|
||||||
|
@change="loadWorklogEntries"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="worklogEntry">Eintrag</label>
|
||||||
|
<select
|
||||||
|
id="worklogEntry"
|
||||||
|
v-model="form.worklogId"
|
||||||
|
@change="onEntrySelected"
|
||||||
|
required
|
||||||
|
:disabled="!availableEntries.length"
|
||||||
|
>
|
||||||
|
<option value="">{{ availableEntries.length === 0 ? '-- Keine Einträge für dieses Datum --' : '-- Bitte wählen --' }}</option>
|
||||||
|
<option
|
||||||
|
v-for="entry in availableEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
:value="entry.id"
|
||||||
|
>
|
||||||
|
{{ entry.time }} - {{ formatAction(entry.action) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zweite Zeile: Neue Werte -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newDate">Neues Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="newDate"
|
||||||
|
v-model="form.newDate"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newTime">Neue Uhrzeit</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
id="newTime"
|
||||||
|
v-model="form.newTime"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newAction">Neuer Eintrags-Typ</label>
|
||||||
|
<select
|
||||||
|
id="newAction"
|
||||||
|
v-model="form.newAction"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Bitte wählen --</option>
|
||||||
|
<option value="start work">Arbeit beginnen</option>
|
||||||
|
<option value="stop work">Arbeit beenden</option>
|
||||||
|
<option value="start pause">Pause beginnen</option>
|
||||||
|
<option value="stop pause">Pause beenden</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Wird gespeichert...' : 'Korrektur speichern' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="resetForm">
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabelle der heutigen Zeitkorrekturen -->
|
||||||
|
<div class="card table-card">
|
||||||
|
<h2>Zeitkorrekturen von heute</h2>
|
||||||
|
|
||||||
|
<div v-if="loading && timefixes.length === 0" class="loading">
|
||||||
|
Lade Zeitkorrekturen...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="timefixes.length === 0" class="empty-state">
|
||||||
|
Keine Zeitkorrekturen für heute vorhanden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="timefix-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="3">Neue Werte</th>
|
||||||
|
<th colspan="3">Originalwerte</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="timefix in timefixes" :key="timefix.id">
|
||||||
|
<td>{{ timefix.newTime }}</td>
|
||||||
|
<td>{{ formatDate(timefix.newDate) }}</td>
|
||||||
|
<td>{{ formatAction(timefix.newAction) }}</td>
|
||||||
|
<td>{{ timefix.originalTime }}</td>
|
||||||
|
<td>{{ formatDate(timefix.originalDate) }}</td>
|
||||||
|
<td>{{ formatAction(timefix.originalAction) }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
@click="deleteTimefix(timefix.id)"
|
||||||
|
class="btn btn-danger btn-small"
|
||||||
|
title="Korrektur rückgängig machen"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import { useModal } from '../composables/useModal'
|
||||||
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const timefixes = ref([])
|
||||||
|
const availableEntries = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
originalDate: new Date().toISOString().split('T')[0],
|
||||||
|
worklogId: '',
|
||||||
|
newDate: new Date().toISOString().split('T')[0],
|
||||||
|
newTime: '',
|
||||||
|
newAction: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lade Worklog-Einträge für das ausgewählte Datum
|
||||||
|
async function loadWorklogEntries() {
|
||||||
|
try {
|
||||||
|
const date = form.value.originalDate
|
||||||
|
if (!date) return
|
||||||
|
|
||||||
|
// Hole alle Worklog-Einträge für das Datum vom Backend
|
||||||
|
const response = await fetch(`http://localhost:3010/api/timefix/worklog-entries?date=${date}`, {
|
||||||
|
headers: authStore.getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Einträge')
|
||||||
|
|
||||||
|
const entries = await response.json()
|
||||||
|
|
||||||
|
// Formatiere die Einträge für das Dropdown
|
||||||
|
availableEntries.value = entries.map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
time: entry.time,
|
||||||
|
action: entry.action,
|
||||||
|
tstamp: entry.tstamp
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Wenn keine Einträge gefunden wurden
|
||||||
|
if (availableEntries.value.length === 0) {
|
||||||
|
console.log('Keine Worklog-Einträge für dieses Datum gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset worklogId wenn Datum geändert wird
|
||||||
|
form.value.worklogId = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Worklog-Einträge:', error)
|
||||||
|
availableEntries.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn ein Eintrag ausgewählt wird, fülle die Felder vor
|
||||||
|
function onEntrySelected() {
|
||||||
|
const selectedEntry = availableEntries.value.find(e => e.id === form.value.worklogId)
|
||||||
|
|
||||||
|
if (selectedEntry) {
|
||||||
|
// Fülle Datum und Aktion vor (aber nicht die Uhrzeit)
|
||||||
|
form.value.newDate = form.value.originalDate
|
||||||
|
form.value.newAction = selectedEntry.action
|
||||||
|
// form.value.newTime bleibt leer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade alle Zeitkorrekturen für heute
|
||||||
|
async function loadTimefixes() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch('http://localhost:3010/api/timefix', {
|
||||||
|
headers: authStore.getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Zeitkorrekturen')
|
||||||
|
|
||||||
|
timefixes.value = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Zeitkorrekturen:', error)
|
||||||
|
alert('Fehler beim Laden der Zeitkorrekturen')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle neue Zeitkorrektur
|
||||||
|
async function createTimefix() {
|
||||||
|
if (!form.value.worklogId || !form.value.newDate || !form.value.newTime || !form.value.newAction) {
|
||||||
|
await alert('Bitte füllen Sie alle Felder aus')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch('http://localhost:3010/api/timefix', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...authStore.getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
worklogId: form.value.worklogId,
|
||||||
|
newDate: form.value.newDate,
|
||||||
|
newTime: form.value.newTime,
|
||||||
|
newAction: form.value.newAction
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Fehler beim Erstellen der Zeitkorrektur')
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
await Promise.all([
|
||||||
|
loadTimefixes(),
|
||||||
|
loadWorklogEntries() // Lade auch die Dropdown-Liste neu
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen der Zeitkorrektur:', error)
|
||||||
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lösche Zeitkorrektur
|
||||||
|
async function deleteTimefix(id) {
|
||||||
|
const confirmed = await confirm('Möchten Sie diese Zeitkorrektur wirklich löschen?', 'Zeitkorrektur löschen')
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch(`http://localhost:3010/api/timefix/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authStore.getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Fehler beim Löschen der Zeitkorrektur')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTimefixes()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen der Zeitkorrektur:', error)
|
||||||
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatiere Datum
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatiere Aktion
|
||||||
|
function formatAction(action) {
|
||||||
|
const actions = {
|
||||||
|
'start work': 'Arbeit beginnen',
|
||||||
|
'stop work': 'Arbeit beenden',
|
||||||
|
'start pause': 'Pause beginnen',
|
||||||
|
'stop pause': 'Pause beenden'
|
||||||
|
}
|
||||||
|
return actions[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formular zurücksetzen
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
originalDate: new Date().toISOString().split('T')[0],
|
||||||
|
worklogId: '',
|
||||||
|
newDate: new Date().toISOString().split('T')[0],
|
||||||
|
newTime: '',
|
||||||
|
newAction: ''
|
||||||
|
}
|
||||||
|
availableEntries.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTimefixes()
|
||||||
|
loadWorklogEntries()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timefix-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formular */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled, select:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ecf0f1;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d5dbdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabelle */
|
||||||
|
.timefix-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timefix-table thead {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timefix-table th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timefix-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timefix-table tbody tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timefix-table .actions {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
384
frontend/src/views/Vacation.vue
Normal file
384
frontend/src/views/Vacation.vue
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vacation-container">
|
||||||
|
<Modal
|
||||||
|
:show="showModal"
|
||||||
|
:title="modalConfig.title"
|
||||||
|
:message="modalConfig.message"
|
||||||
|
:type="modalConfig.type"
|
||||||
|
:confirmText="modalConfig.confirmText"
|
||||||
|
:cancelText="modalConfig.cancelText"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
/>
|
||||||
|
<!-- Formular für neuen Urlaubseintrag -->
|
||||||
|
<div class="card form-card">
|
||||||
|
<form @submit.prevent="createVacation" class="vacation-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="vacationType">Umfang</label>
|
||||||
|
<select
|
||||||
|
id="vacationType"
|
||||||
|
v-model="form.vacationType"
|
||||||
|
@change="onTypeChange"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option :value="0">Zeitraum</option>
|
||||||
|
<option :value="1">Halber Tag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="startDate">Urlaubsbeginn</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="startDate"
|
||||||
|
v-model="form.startDate"
|
||||||
|
@change="onStartDateChange"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="endDate">Urlaubsende</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="endDate"
|
||||||
|
v-model="form.endDate"
|
||||||
|
:disabled="form.vacationType === 1"
|
||||||
|
:required="form.vacationType === 0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Wird gespeichert...' : 'Urlaub eintragen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="separator" />
|
||||||
|
|
||||||
|
<!-- Tabelle der Urlaubseinträge -->
|
||||||
|
<div class="card table-card">
|
||||||
|
<div v-if="loading && vacations.length === 0" class="loading">
|
||||||
|
Lade Urlaubseinträge...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="vacations.length === 0" class="empty-state">
|
||||||
|
Keine Urlaubseinträge vorhanden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="vacation-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Umfang</th>
|
||||||
|
<th>Urlaubsbeginn</th>
|
||||||
|
<th>Urlaubsende</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="vacation in vacations" :key="vacation.id">
|
||||||
|
<td>{{ vacation.type }}</td>
|
||||||
|
<td>{{ formatDate(vacation.startDate) }}</td>
|
||||||
|
<td>{{ formatDate(vacation.endDate) }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
@click="deleteVacation(vacation.id)"
|
||||||
|
class="btn btn-danger btn-small"
|
||||||
|
title="Urlaubseintrag löschen"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
import { useModal } from '../composables/useModal'
|
||||||
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const vacations = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
vacationType: 0, // 0 = Zeitraum, 1 = Halber Tag
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wenn Typ geändert wird
|
||||||
|
function onTypeChange() {
|
||||||
|
if (form.value.vacationType === 1) {
|
||||||
|
// Halber Tag: endDate = startDate
|
||||||
|
form.value.endDate = form.value.startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn Startdatum geändert wird bei "Halber Tag"
|
||||||
|
function onStartDateChange() {
|
||||||
|
if (form.value.vacationType === 1) {
|
||||||
|
form.value.endDate = form.value.startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade alle Urlaubseinträge
|
||||||
|
async function loadVacations() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch('http://localhost:3010/api/vacation', {
|
||||||
|
headers: authStore.getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Urlaubseinträge')
|
||||||
|
|
||||||
|
vacations.value = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Urlaubseinträge:', error)
|
||||||
|
await alert('Fehler beim Laden der Urlaubseinträge', 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle neuen Urlaubseintrag
|
||||||
|
async function createVacation() {
|
||||||
|
if (!form.value.startDate || (form.value.vacationType === 0 && !form.value.endDate)) {
|
||||||
|
await alert('Bitte füllen Sie alle Felder aus')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch('http://localhost:3010/api/vacation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...authStore.getAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
vacationType: form.value.vacationType,
|
||||||
|
startDate: form.value.startDate,
|
||||||
|
endDate: form.value.endDate
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Fehler beim Erstellen des Urlaubseintrags')
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
await loadVacations()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Erstellen des Urlaubseintrags:', error)
|
||||||
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lösche Urlaubseintrag
|
||||||
|
async function deleteVacation(id) {
|
||||||
|
const confirmed = await confirm('Möchten Sie diesen Urlaubseintrag wirklich löschen?', 'Urlaubseintrag löschen')
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await fetch(`http://localhost:3010/api/vacation/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authStore.getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.message || 'Fehler beim Löschen des Urlaubseintrags')
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadVacations()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen des Urlaubseintrags:', error)
|
||||||
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatiere Datum
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
const date = new Date(dateString + 'T00:00:00')
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formular zurücksetzen
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
vacationType: 0,
|
||||||
|
startDate: '',
|
||||||
|
endDate: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadVacations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vacation-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formular */
|
||||||
|
.vacation-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
flex: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabelle */
|
||||||
|
.vacation-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vacation-table thead {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vacation-table th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vacation-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vacation-table tbody tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vacation-table .actions {
|
||||||
|
text-align: center;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="week-overview">
|
<div class="week-overview">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Wochenübersicht</h2>
|
|
||||||
<p class="subtitle">Übersicht Ihrer Arbeitszeiten für die aktuelle Woche</p>
|
|
||||||
|
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" class="loading">
|
||||||
Lade Daten...
|
Lade Daten...
|
||||||
|
|||||||
Reference in New Issue
Block a user