Add workdays routes to backend and frontend; update routing and UI components for workdays tracking
This commit is contained in:
35
backend/src/controllers/WorkdaysController.js
Normal file
35
backend/src/controllers/WorkdaysController.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const WorkdaysService = require('../services/WorkdaysService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller für Arbeitstage-Statistiken
|
||||||
|
* Verarbeitet HTTP-Requests und delegiert an WorkdaysService
|
||||||
|
*/
|
||||||
|
class WorkdaysController {
|
||||||
|
/**
|
||||||
|
* Holt Arbeitstage-Statistiken für ein Jahr
|
||||||
|
*/
|
||||||
|
async getStatistics(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id || 1;
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||||
|
|
||||||
|
if (isNaN(year) || year < 1900 || year > 2100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Ungültiges Jahr'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statistics = await WorkdaysService.getWorkdaysStatistics(userId, year);
|
||||||
|
res.json(statistics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abrufen der Arbeitstage-Statistiken:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Fehler beim Abrufen der Arbeitstage-Statistiken',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new WorkdaysController();
|
||||||
|
|
||||||
@@ -78,6 +78,10 @@ app.use('/api/vacation', authenticateToken, vacationRouter);
|
|||||||
const sickRouter = require('./routes/sick');
|
const sickRouter = require('./routes/sick');
|
||||||
app.use('/api/sick', authenticateToken, sickRouter);
|
app.use('/api/sick', authenticateToken, sickRouter);
|
||||||
|
|
||||||
|
// Workdays routes (geschützt) - MIT ID-Hashing
|
||||||
|
const workdaysRouter = require('./routes/workdays');
|
||||||
|
app.use('/api/workdays', authenticateToken, workdaysRouter);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|||||||
13
backend/src/routes/workdays.js
Normal file
13
backend/src/routes/workdays.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const WorkdaysController = require('../controllers/WorkdaysController');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routen für Arbeitstage-Statistiken
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /api/workdays?year=2025 - Statistiken für ein Jahr abrufen
|
||||||
|
router.get('/', WorkdaysController.getStatistics.bind(WorkdaysController));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
182
backend/src/services/WorkdaysService.js
Normal file
182
backend/src/services/WorkdaysService.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
const database = require('../config/database');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-Klasse für Arbeitstage-Statistiken
|
||||||
|
* Berechnet verschiedene Tage-Typen für ein Jahr
|
||||||
|
*/
|
||||||
|
class WorkdaysService {
|
||||||
|
constructor() {
|
||||||
|
this.defaultUserId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet Arbeitstage-Statistiken für ein Jahr
|
||||||
|
* @param {number} userId - Benutzer-ID
|
||||||
|
* @param {number} year - Jahr
|
||||||
|
* @returns {Promise<Object>} Statistiken
|
||||||
|
*/
|
||||||
|
async getWorkdaysStatistics(userId, year) {
|
||||||
|
const { Holiday, Sick, Vacation } = database.getModels();
|
||||||
|
const sequelize = database.sequelize;
|
||||||
|
|
||||||
|
// Berechne Anzahl Werktage (Mo-Fr) im Jahr
|
||||||
|
const workdays = this._countWorkdays(year);
|
||||||
|
|
||||||
|
// Hole alle Feiertage für das Jahr
|
||||||
|
const holidays = await Holiday.findAll({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
[Op.gte]: `${year}-01-01`,
|
||||||
|
[Op.lte]: `${year}-12-31`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zähle nur Feiertage die auf Werktage fallen
|
||||||
|
const holidayCount = holidays.filter(h => {
|
||||||
|
const holidayDate = new Date(h.date + 'T00:00:00');
|
||||||
|
const dayOfWeek = holidayDate.getDay();
|
||||||
|
return dayOfWeek >= 1 && dayOfWeek <= 5; // Mo-Fr
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// Hole alle Krankheitstage für das Jahr
|
||||||
|
const sickEntries = await Sick.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
|
first_day: {
|
||||||
|
[Op.between]: [`${year}-01-01`, `${year}-12-31`]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
last_day: {
|
||||||
|
[Op.between]: [`${year}-01-01`, `${year}-12-31`]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Op.and]: [
|
||||||
|
{ first_day: { [Op.lte]: `${year}-01-01` } },
|
||||||
|
{ last_day: { [Op.gte]: `${year}-12-31` } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zähle Krankheitstage (nur Werktage)
|
||||||
|
let sickDays = 0;
|
||||||
|
sickEntries.forEach(sick => {
|
||||||
|
const start = new Date(Math.max(new Date(sick.first_day + 'T00:00:00'), new Date(`${year}-01-01T00:00:00`)));
|
||||||
|
const end = new Date(Math.min(new Date(sick.last_day + 'T00:00:00'), new Date(`${year}-12-31T00:00:00`)));
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Mo-Fr
|
||||||
|
sickDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hole alle Urlaubstage für das Jahr
|
||||||
|
const vacationEntries = await Vacation.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
|
first_day: {
|
||||||
|
[Op.between]: [`${year}-01-01`, `${year}-12-31`]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
last_day: {
|
||||||
|
[Op.between]: [`${year}-01-01`, `${year}-12-31`]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[Op.and]: [
|
||||||
|
{ first_day: { [Op.lte]: `${year}-01-01` } },
|
||||||
|
{ last_day: { [Op.gte]: `${year}-12-31` } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zähle Urlaubstage (nur Werktage, berücksichtige halbe Tage)
|
||||||
|
let vacationDays = 0;
|
||||||
|
vacationEntries.forEach(vac => {
|
||||||
|
const start = new Date(Math.max(new Date(vac.first_day + 'T00:00:00'), new Date(`${year}-01-01T00:00:00`)));
|
||||||
|
const end = new Date(Math.min(new Date(vac.last_day + 'T00:00:00'), new Date(`${year}-12-31T00:00:00`)));
|
||||||
|
|
||||||
|
const isHalfDay = vac.half_day === 1;
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Mo-Fr
|
||||||
|
vacationDays += isHalfDay ? 0.5 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zähle gearbeitete Tage (Tage mit worklog-Einträgen)
|
||||||
|
const workedDaysQuery = `
|
||||||
|
SELECT COUNT(DISTINCT DATE(tstamp)) as count
|
||||||
|
FROM worklog
|
||||||
|
WHERE user_id = :userId
|
||||||
|
AND DATE(tstamp) BETWEEN :startDate AND :endDate
|
||||||
|
AND state LIKE '%start work%'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const workedDaysResult = await sequelize.query(workedDaysQuery, {
|
||||||
|
replacements: {
|
||||||
|
userId,
|
||||||
|
startDate: `${year}-01-01`,
|
||||||
|
endDate: `${year}-12-31`
|
||||||
|
},
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
const workedDays = workedDaysResult[0]?.count || 0;
|
||||||
|
|
||||||
|
// Berechne Prozentsatz Krankheitstage
|
||||||
|
const sickPercentage = workdays > 0 ? Math.round((sickDays / workdays) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
workdays,
|
||||||
|
holidays: holidayCount,
|
||||||
|
sickDays,
|
||||||
|
sickPercentage,
|
||||||
|
vacationDays,
|
||||||
|
workedDays
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt Werktage (Mo-Fr) in einem Jahr
|
||||||
|
* @param {number} year - Jahr
|
||||||
|
* @returns {number} Anzahl Werktage
|
||||||
|
*/
|
||||||
|
_countWorkdays(year) {
|
||||||
|
let count = 0;
|
||||||
|
const start = new Date(year, 0, 1);
|
||||||
|
const end = new Date(year, 11, 31);
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Mo-Fr
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new WorkdaysService();
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ const pageTitle = computed(() => {
|
|||||||
'timefix': 'Zeitkorrekturen',
|
'timefix': 'Zeitkorrekturen',
|
||||||
'vacation': 'Urlaub',
|
'vacation': 'Urlaub',
|
||||||
'sick': 'Krankheit',
|
'sick': 'Krankheit',
|
||||||
|
'workdays': 'Arbeitstage',
|
||||||
'entries': 'Einträge',
|
'entries': 'Einträge',
|
||||||
'stats': 'Statistiken'
|
'stats': 'Statistiken'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import WeekOverview from '../views/WeekOverview.vue'
|
|||||||
import Timefix from '../views/Timefix.vue'
|
import Timefix from '../views/Timefix.vue'
|
||||||
import Vacation from '../views/Vacation.vue'
|
import Vacation from '../views/Vacation.vue'
|
||||||
import Sick from '../views/Sick.vue'
|
import Sick from '../views/Sick.vue'
|
||||||
|
import Workdays from '../views/Workdays.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -77,6 +78,12 @@ const router = createRouter({
|
|||||||
component: Sick,
|
component: Sick,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/bookings/workdays',
|
||||||
|
name: 'workdays',
|
||||||
|
component: Workdays,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/entries',
|
path: '/entries',
|
||||||
name: 'entries',
|
name: 'entries',
|
||||||
|
|||||||
181
frontend/src/views/Workdays.vue
Normal file
181
frontend/src/views/Workdays.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="workdays-page">
|
||||||
|
<div class="card">
|
||||||
|
<!-- Jahr-Auswahl -->
|
||||||
|
<div class="year-selector">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="selectedYear"
|
||||||
|
@change="loadStatistics"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
class="year-input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistik-Tabelle -->
|
||||||
|
<table class="stats-table" v-if="statistics">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Werktage</th>
|
||||||
|
<td>{{ statistics.workdays }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Feiertage</th>
|
||||||
|
<td>{{ statistics.holidays }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Krankheitstage</th>
|
||||||
|
<td>{{ statistics.sickDays }} ({{ statistics.sickPercentage }} %)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Urlaubstage</th>
|
||||||
|
<td>{{ formatVacationDays(statistics.vacationDays) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Gearbeitete Tage</th>
|
||||||
|
<td>{{ statistics.workedDays }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Lade Statistiken...</div>
|
||||||
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const selectedYear = ref(new Date().getFullYear())
|
||||||
|
const statistics = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
// Lade Statistiken für das ausgewählte Jahr
|
||||||
|
async function loadStatistics() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:3010/api/workdays?year=${selectedYear.value}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${authStore.token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der Statistiken')
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics.value = await response.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Statistiken:', err)
|
||||||
|
error.value = err.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatiere Urlaubstage (zeige halbe Tage korrekt)
|
||||||
|
function formatVacationDays(days) {
|
||||||
|
if (days === Math.floor(days)) {
|
||||||
|
return days.toString()
|
||||||
|
}
|
||||||
|
return days.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiales Laden
|
||||||
|
onMounted(() => {
|
||||||
|
loadStatistics()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workdays-page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-selector {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-input {
|
||||||
|
width: 120px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table tr {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 70%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table td {
|
||||||
|
text-align: right;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #f44336;
|
||||||
|
background: #ffebee;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
Reference in New Issue
Block a user