From 67ddf812cd172d2705caec71f5bac5a9288cf99f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 22:35:31 +0200 Subject: [PATCH] Add holidays routes to backend and frontend; implement holiday associations and update UI components for admin holidays management --- backend/set-user-admin.sql | 24 + backend/src/config/database.js | 23 +- backend/src/controllers/HolidayController.js | 86 +++ backend/src/index.js | 4 + backend/src/models/Holiday.js | 13 + backend/src/models/HolidayState.js | 69 +++ backend/src/models/State.js | 13 + backend/src/models/User.js | 18 + backend/src/models/index.js | 2 + backend/src/routes/holidays.js | 23 + backend/src/services/HolidayService.js | 180 ++++++ frontend/src/App.vue | 1 + frontend/src/components/SideMenu.vue | 12 +- frontend/src/router/index.js | 7 + frontend/src/views/Holidays.vue | 585 +++++++++++++++++++ 15 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 backend/set-user-admin.sql create mode 100644 backend/src/controllers/HolidayController.js create mode 100644 backend/src/models/HolidayState.js create mode 100644 backend/src/routes/holidays.js create mode 100644 backend/src/services/HolidayService.js create mode 100644 frontend/src/views/Holidays.vue diff --git a/backend/set-user-admin.sql b/backend/set-user-admin.sql new file mode 100644 index 0000000..62bc719 --- /dev/null +++ b/backend/set-user-admin.sql @@ -0,0 +1,24 @@ +-- SQL Script: Benutzer zu Admin machen +-- Setzt die Rolle eines Benutzers auf 'admin' +-- Ausführen mit: mysql -u stechuhr -p stechuhr < set-user-admin.sql + +USE stechuhr; + +-- Zeige aktuelle Benutzer und ihre Rollen +-- role: 0 = user, 1 = admin +SELECT id, full_name, role FROM user; + +-- Setze User mit ID 1 auf Admin (role = 1) +UPDATE user SET role = 1 WHERE id = 1; + +-- Zeige das Ergebnis +SELECT id, full_name, role, + CASE role + WHEN 0 THEN 'user' + WHEN 1 THEN 'admin' + ELSE 'unknown' + END as role_name +FROM user WHERE id = 1; + +SELECT 'Benutzer erfolgreich zu Admin gemacht!' AS status; + diff --git a/backend/src/config/database.js b/backend/src/config/database.js index a154a39..42d934b 100644 --- a/backend/src/config/database.js +++ b/backend/src/config/database.js @@ -76,6 +76,7 @@ class Database { const State = require('../models/State'); const WeeklyWorktime = require('../models/WeeklyWorktime'); const Holiday = require('../models/Holiday'); + const HolidayState = require('../models/HolidayState'); const Vacation = require('../models/Vacation'); const Sick = require('../models/Sick'); const SickType = require('../models/SickType'); @@ -91,6 +92,7 @@ class Database { State.initialize(this.sequelize); WeeklyWorktime.initialize(this.sequelize); Holiday.initialize(this.sequelize); + HolidayState.initialize(this.sequelize); Vacation.initialize(this.sequelize); Sick.initialize(this.sequelize); SickType.initialize(this.sequelize); @@ -107,7 +109,7 @@ class Database { * Model-Assoziationen definieren */ defineAssociations() { - const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Vacation, Sick, SickType, Timefix, Timewish } = this.sequelize.models; + const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish } = this.sequelize.models; // User Assoziationen User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' }); @@ -151,6 +153,25 @@ class Database { // SickType Assoziationen SickType.hasMany(Sick, { foreignKey: 'sick_type_id', as: 'sickLeaves' }); + // Holiday Assoziationen (Many-to-Many mit State) + Holiday.belongsToMany(State, { + through: HolidayState, + foreignKey: 'holiday_id', + otherKey: 'state_id', + as: 'states' + }); + + State.belongsToMany(Holiday, { + through: HolidayState, + foreignKey: 'state_id', + otherKey: 'holiday_id', + as: 'holidays' + }); + + // HolidayState Assoziationen + HolidayState.belongsTo(Holiday, { foreignKey: 'holiday_id', as: 'holiday' }); + HolidayState.belongsTo(State, { foreignKey: 'state_id', as: 'state' }); + // Timewish Assoziationen Timewish.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); User.hasMany(Timewish, { foreignKey: 'user_id', as: 'timewishes' }); diff --git a/backend/src/controllers/HolidayController.js b/backend/src/controllers/HolidayController.js new file mode 100644 index 0000000..44ec513 --- /dev/null +++ b/backend/src/controllers/HolidayController.js @@ -0,0 +1,86 @@ +const HolidayService = require('../services/HolidayService'); + +/** + * Controller für Feiertage + * Verarbeitet HTTP-Requests und delegiert an HolidayService + */ +class HolidayController { + /** + * Holt alle Bundesländer + */ + async getAllStates(req, res) { + try { + const states = await HolidayService.getAllStates(); + res.json(states); + } catch (error) { + console.error('Fehler beim Abrufen der Bundesländer:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen der Bundesländer', + error: error.message + }); + } + } + + /** + * Holt alle Feiertage + */ + async getAllHolidays(req, res) { + try { + const holidays = await HolidayService.getAllHolidays(); + res.json(holidays); + } catch (error) { + console.error('Fehler beim Abrufen der Feiertage:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen der Feiertage', + error: error.message + }); + } + } + + /** + * Erstellt einen neuen Feiertag + */ + async createHoliday(req, res) { + try { + const { date, hours, description, stateIds } = req.body; + + if (!date || !description) { + return res.status(400).json({ + message: 'Datum und Beschreibung sind erforderlich' + }); + } + + const holiday = await HolidayService.createHoliday(date, hours, description, stateIds); + res.status(201).json(holiday); + } catch (error) { + console.error('Fehler beim Erstellen des Feiertags:', error); + res.status(error.message.includes('existiert bereits') ? 409 : 500).json({ + message: error.message + }); + } + } + + /** + * Löscht einen Feiertag + */ + async deleteHoliday(req, res) { + try { + const holidayId = parseInt(req.params.id); + + if (isNaN(holidayId)) { + return res.status(400).json({ message: 'Ungültige ID' }); + } + + await HolidayService.deleteHoliday(holidayId); + res.json({ message: 'Feiertag gelöscht' }); + } catch (error) { + console.error('Fehler beim Löschen des Feiertags:', error); + res.status(error.message.includes('nicht gefunden') ? 404 : 500).json({ + message: error.message + }); + } + } +} + +module.exports = new HolidayController(); + diff --git a/backend/src/index.js b/backend/src/index.js index d0725cf..5844b64 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -86,6 +86,10 @@ app.use('/api/workdays', authenticateToken, workdaysRouter); const calendarRouter = require('./routes/calendar'); app.use('/api/calendar', authenticateToken, calendarRouter); +// Holidays routes (geschützt, nur Admin) - MIT ID-Hashing +const holidaysRouter = require('./routes/holidays'); +app.use('/api/holidays', authenticateToken, holidaysRouter); + // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/models/Holiday.js b/backend/src/models/Holiday.js index 71d6946..e922fc6 100644 --- a/backend/src/models/Holiday.js +++ b/backend/src/models/Holiday.js @@ -42,6 +42,19 @@ class Holiday extends Model { return Holiday; } + + /** + * Definiert Assoziationen mit anderen Models + */ + static associate(models) { + // Holiday hat viele States (Many-to-Many über HolidayState) + Holiday.belongsToMany(models.State, { + through: models.HolidayState, + foreignKey: 'holiday_id', + otherKey: 'state_id', + as: 'states' + }); + } } module.exports = Holiday; diff --git a/backend/src/models/HolidayState.js b/backend/src/models/HolidayState.js new file mode 100644 index 0000000..b119df2 --- /dev/null +++ b/backend/src/models/HolidayState.js @@ -0,0 +1,69 @@ +const { Model, DataTypes } = require('sequelize'); + +/** + * HolidayState Model + * Junction Table für Many-to-Many Beziehung zwischen Holiday und State + */ +class HolidayState extends Model { + static initialize(sequelize) { + HolidayState.init( + { + holiday_id: { + type: DataTypes.BIGINT, + primaryKey: true, + allowNull: false, + references: { + model: 'holiday', + key: 'id' + } + }, + state_id: { + type: DataTypes.BIGINT, + primaryKey: true, + allowNull: false, + references: { + model: 'state', + key: 'id' + } + } + }, + { + sequelize, + tableName: 'holiday_state', + timestamps: false, + indexes: [ + { + name: 'fk_holiday_state_holiday', + fields: ['holiday_id'] + }, + { + name: 'fk_holiday_state_state', + fields: ['state_id'] + } + ] + } + ); + + return HolidayState; + } + + /** + * Definiert Assoziationen mit anderen Models + */ + static associate(models) { + // HolidayState gehört zu einem Holiday + HolidayState.belongsTo(models.Holiday, { + foreignKey: 'holiday_id', + as: 'holiday' + }); + + // HolidayState gehört zu einem State + HolidayState.belongsTo(models.State, { + foreignKey: 'state_id', + as: 'state' + }); + } +} + +module.exports = HolidayState; + diff --git a/backend/src/models/State.js b/backend/src/models/State.js index 6070f99..33865d1 100644 --- a/backend/src/models/State.js +++ b/backend/src/models/State.js @@ -33,6 +33,19 @@ class State extends Model { return State; } + + /** + * Definiert Assoziationen mit anderen Models + */ + static associate(models) { + // State hat viele Holidays (Many-to-Many über HolidayState) + State.belongsToMany(models.Holiday, { + through: models.HolidayState, + foreignKey: 'state_id', + otherKey: 'holiday_id', + as: 'holidays' + }); + } } module.exports = State; diff --git a/backend/src/models/User.js b/backend/src/models/User.js index d30a1c8..bc218df 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -99,6 +99,24 @@ class User extends Model { getWeeklyHours() { return this.week_hours; } + + /** + * Rolle als String zurückgeben + * 0 = 'user', 1 = 'admin' + */ + getRoleString() { + return this.role === 1 ? 'admin' : 'user'; + } + + /** + * JSON-Serialisierung überschreiben + */ + toJSON() { + const values = { ...this.get() }; + // Füge role_string hinzu für Frontend + values.role_string = this.getRoleString(); + return values; + } } module.exports = User; diff --git a/backend/src/models/index.js b/backend/src/models/index.js index c491ed8..1bd28e0 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -9,6 +9,7 @@ const AuthInfo = require('./AuthInfo'); const State = require('./State'); const WeeklyWorktime = require('./WeeklyWorktime'); const Holiday = require('./Holiday'); +const HolidayState = require('./HolidayState'); const Vacation = require('./Vacation'); const Sick = require('./Sick'); const SickType = require('./SickType'); @@ -22,6 +23,7 @@ module.exports = { State, WeeklyWorktime, Holiday, + HolidayState, Vacation, Sick, SickType, diff --git a/backend/src/routes/holidays.js b/backend/src/routes/holidays.js new file mode 100644 index 0000000..b930284 --- /dev/null +++ b/backend/src/routes/holidays.js @@ -0,0 +1,23 @@ +const express = require('express'); +const router = express.Router(); +const HolidayController = require('../controllers/HolidayController'); +const unhashRequestIds = require('../middleware/unhashRequest'); + +/** + * Routen für Feiertage (nur für Admins) + */ + +// GET /api/holidays/states - Alle Bundesländer abrufen +router.get('/states', HolidayController.getAllStates.bind(HolidayController)); + +// GET /api/holidays - Alle Feiertage abrufen +router.get('/', HolidayController.getAllHolidays.bind(HolidayController)); + +// POST /api/holidays - Neuen Feiertag erstellen +router.post('/', HolidayController.createHoliday.bind(HolidayController)); + +// DELETE /api/holidays/:id - Feiertag löschen +router.delete('/:id', unhashRequestIds, HolidayController.deleteHoliday.bind(HolidayController)); + +module.exports = router; + diff --git a/backend/src/services/HolidayService.js b/backend/src/services/HolidayService.js new file mode 100644 index 0000000..b2e1cfb --- /dev/null +++ b/backend/src/services/HolidayService.js @@ -0,0 +1,180 @@ +const database = require('../config/database'); +const { Op } = require('sequelize'); + +/** + * Service-Klasse für Feiertage + * Verwaltet bundesweite und regionale Feiertage + */ +class HolidayService { + /** + * Holt alle verfügbaren Bundesländer + * @returns {Promise} Array von States + */ + async getAllStates() { + const { State } = database.getModels(); + + const states = await State.findAll({ + order: [['state_name', 'ASC']], + raw: true + }); + + return states.map(s => ({ + id: s.id, + name: s.state_name + })); + } + + /** + * Holt alle Feiertage, aufgeteilt in zukünftige und vergangene + * Vergangene: Nur aktuelles Jahr und maximal 3 Monate zurück + * @returns {Promise} { future: [], past: [] } + */ + async getAllHolidays() { + const { Holiday, State, HolidayState } = database.getModels(); + + const today = new Date(); + const currentYear = today.getFullYear(); + const todayStr = `${currentYear}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // Berechne Startdatum für vergangene Feiertage + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(today.getMonth() - 3); + const yearStart = new Date(currentYear, 0, 1); + const pastStartDate = yearStart < threeMonthsAgo ? yearStart : threeMonthsAgo; + const pastStartDateStr = `${pastStartDate.getFullYear()}-${String(pastStartDate.getMonth() + 1).padStart(2, '0')}-${String(pastStartDate.getDate()).padStart(2, '0')}`; + + // Zukünftige Feiertage (inkl. heute) mit States + const futureHolidays = await Holiday.findAll({ + where: { + date: { + [Op.gte]: todayStr + } + }, + include: [ + { + model: State, + as: 'states', + attributes: ['id', 'state_name'], + through: { attributes: [] } // Keine Attribute der Junction Table + } + ], + order: [['date', 'ASC']] + }); + + // Vergangene Feiertage (nur aktuelles Jahr / letzte 3 Monate) mit States + const pastHolidays = await Holiday.findAll({ + where: { + date: { + [Op.gte]: pastStartDateStr, + [Op.lt]: todayStr + } + }, + include: [ + { + model: State, + as: 'states', + attributes: ['id', 'state_name'], + through: { attributes: [] } + } + ], + order: [['date', 'DESC']] + }); + + return { + future: futureHolidays.map(h => this._formatHoliday(h)), + past: pastHolidays.map(h => this._formatHoliday(h)) + }; + } + + /** + * Erstellt einen neuen Feiertag + * @param {string} date - Datum (YYYY-MM-DD) + * @param {number} hours - Freie Stunden (Standard: 8) + * @param {string} description - Beschreibung + * @param {Array} stateIds - Array von State-IDs (leer = bundesweit) + * @returns {Promise} Erstellter Feiertag + */ + async createHoliday(date, hours, description, stateIds = []) { + const { Holiday, State, HolidayState } = database.getModels(); + + // Prüfe ob schon ein Feiertag an diesem Datum existiert + const existing = await Holiday.findOne({ + where: { date } + }); + + if (existing) { + throw new Error('An diesem Datum existiert bereits ein Feiertag'); + } + + const holiday = await Holiday.create({ + date, + hours: hours || 8, + description, + version: 0 + }); + + // Verknüpfe mit States (falls angegeben) + if (stateIds && stateIds.length > 0) { + for (const stateId of stateIds) { + await HolidayState.create({ + holiday_id: holiday.id, + state_id: stateId + }); + } + } + + // Lade Holiday mit States neu + const holidayWithStates = await Holiday.findByPk(holiday.id, { + include: [ + { + model: State, + as: 'states', + attributes: ['id', 'state_name'], + through: { attributes: [] } + } + ] + }); + + return this._formatHoliday(holidayWithStates); + } + + /** + * Löscht einen Feiertag + * @param {number} id - Holiday-ID + * @returns {Promise} + */ + async deleteHoliday(id) { + const { Holiday } = database.getModels(); + + const holiday = await Holiday.findByPk(id); + + if (!holiday) { + throw new Error('Feiertag nicht gefunden'); + } + + await holiday.destroy(); + } + + /** + * Formatiert einen Feiertag für die API + */ + _formatHoliday(holiday) { + // Holiday kann ein Plain Object (raw: true) oder eine Sequelize Instance sein + const states = holiday.states || []; + const stateNames = Array.isArray(states) + ? states.map(s => s.state_name || s.name).filter(Boolean) + : []; + + return { + id: holiday.id, + date: holiday.date, + hours: holiday.hours, + description: holiday.description, + states: stateNames, + isFederal: stateNames.length === 0 // Kein State = Bundesfeiertag + }; + } +} + +module.exports = new HolidayService(); + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6893fa8..d0a0954 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -72,6 +72,7 @@ const pageTitle = computed(() => { 'sick': 'Krankheit', 'workdays': 'Arbeitstage', 'calendar': 'Kalender', + 'admin-holidays': 'Feiertage', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/components/SideMenu.vue b/frontend/src/components/SideMenu.vue index 7987237..13677d3 100644 --- a/frontend/src/components/SideMenu.vue +++ b/frontend/src/components/SideMenu.vue @@ -44,7 +44,17 @@ import { useAuthStore } from '../stores/authStore' const auth = useAuthStore() // Rolle: 'user' | 'admin' (Fallback: 'user') -const role = computed(() => (auth.user?.role || 'user').toString().toLowerCase()) +// Verwende role_string (vom Backend gemappt) oder fallback zu 'user' +const role = computed(() => { + if (auth.user?.role_string) { + return auth.user.role_string.toLowerCase() + } + // Fallback für alte Implementierung oder wenn role ein Number ist + if (typeof auth.user?.role === 'number') { + return auth.user.role === 1 ? 'admin' : 'user' + } + return (auth.user?.role || 'user').toString().toLowerCase() +}) const SECTIONS_USER = [ { diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 789e729..6c69962 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -15,6 +15,7 @@ import Vacation from '../views/Vacation.vue' import Sick from '../views/Sick.vue' import Workdays from '../views/Workdays.vue' import Calendar from '../views/Calendar.vue' +import Holidays from '../views/Holidays.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -91,6 +92,12 @@ const router = createRouter({ component: Calendar, meta: { requiresAuth: true } }, + { + path: '/admin/holidays', + name: 'admin-holidays', + component: Holidays, + meta: { requiresAuth: true, requiresAdmin: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Holidays.vue b/frontend/src/views/Holidays.vue new file mode 100644 index 0000000..83923ba --- /dev/null +++ b/frontend/src/views/Holidays.vue @@ -0,0 +1,585 @@ + + + + + +