From b2823731f1a6cefa7e5314aafcc40c1211e0d12c Mon Sep 17 00:00:00 2001 From: Felix Schlusche Date: Thu, 23 Oct 2025 02:43:47 +0200 Subject: [PATCH] Add settings management with Bundesland selection and holiday calculations --- README.md | 100 +++++++++++++++++--- db/schema.sql | 6 ++ public/app.js | 232 ++++++++++++++++++++++++++++++++++++++++++---- public/index.html | 32 +++++++ server.js | 79 ++++++++++++++++ 5 files changed, 419 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7ce2c05..9548d4c 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,58 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite ## Funktionen -- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit) -- ✅ Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz -- ✅ Maximum von 10 Stunden Nettoarbeitszeit -- ✅ Filterung nach Zeitraum -- ✅ CSV-Export mit deutscher Formatierung -- ✅ Responsive Benutzeroberfläche mit Tailwind CSS -- ✅ Moderner Datums-/Zeitauswahl (Flatpickr) -- ✅ Docker-Containerisierung +### Zeiterfassung +- ✅ **Start/Stop Timer**: Live-Timer mit automatischer Zeiterfassung für den aktuellen Tag + - Pause nach 6 Stunden (30 Min) oder 9 Stunden (45 Min) gemäß deutschem Arbeitszeitgesetz + - Automatische Rundung auf 15-Minuten-Intervalle + - Timer läuft auch nach Seiten-Reload weiter +- ✅ **Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause) +- ✅ **Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle +- ✅ **Standort-Tracking**: Home-Office oder Büro pro Eintrag + +### Intelligente Berechnungen +- ✅ **Automatische Pausenberechnung** nach deutschem Arbeitszeitgesetz: + - \> 6 Stunden: 30 Minuten Pause + - \> 9 Stunden: 45 Minuten Pause +- ✅ **Maximum von 10 Stunden Nettoarbeitszeit** pro Tag +- ✅ **Monatliche Statistiken**: Soll-Stunden, Ist-Stunden, Saldo (Monat + Gesamt) +- ✅ **Arbeitstage-Berechnung**: Automatische Erkennung von Wochenenden und Feiertagen + +### Bundesland-spezifische Feiertage +- ✅ **16 Bundesländer**: Auswahl des Bundeslandes für korrekte Feiertagsberechnung +- ✅ **Persistente Einstellung**: Bundesland-Auswahl wird gespeichert +- ✅ **Kollisionserkennung**: Warnung bei Feiertagen, die mit bestehenden Einträgen kollidieren +- ✅ **Alle regionalen Feiertage**: Heilige Drei Könige, Fronleichnam, Reformationstag, etc. + +### Monatsansicht & Navigation +- ✅ **Monatskalender**: Vollständige Ansicht aller Tage des Monats +- ✅ **Farbcodierung**: + - Grün: Home-Office Tage + - Rot: Fehlende Einträge an Arbeitstagen + - Grau: Wochenenden + - Blau: Feiertage mit Namen +- ✅ **Vor/Zurück Navigation**: Einfaches Wechseln zwischen Monaten +- ✅ **Auto-Fill Funktion**: Automatisches Ausfüllen des gesamten Monats mit Standard-Arbeitszeiten + +### Bulk-Operationen +- ✅ **Mehrfachauswahl**: Checkbox-Modus für schnelle Massenbearbeitung +- ✅ **Bulk-Standort setzen**: Mehrere Einträge auf einmal auf Home/Büro setzen +- ✅ **Bulk-Löschen**: Mehrere Einträge auf einmal löschen + +### Filter & Export +- ✅ **Zeitraum-Filter**: Filterung nach Datum (Von/Bis) +- ✅ **CSV-Export (Alle)**: Export aller Einträge im gewählten Zeitraum +- ✅ **CSV-Export (Abweichungen)**: Export nur der Tage mit Abweichungen von 8,0 Stunden + - Ideal für Arbeitszeitnachweise bei Gleitzeit + - Zeigt nur relevante Über-/Unterschreitungen +- ✅ **Deutsches Format**: Komma als Dezimaltrennzeichen, DD.MM.YYYY Datumsformat + +### Benutzerfreundlichkeit +- ✅ **Responsive Design**: Optimiert für Desktop, Tablet und Smartphone +- ✅ **Dark Mode**: Modernes dunkles Design für augenschonende Arbeit +- ✅ **Toast-Benachrichtigungen**: Visuelles Feedback bei Aktionen +- ✅ **Flatpickr**: Moderne Datums- und Zeitauswahl mit Touch-Support +- ✅ **Persistente Daten**: SQLite-Datenbank mit automatischer Migration ## Technologie-Stack @@ -61,11 +105,20 @@ Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitsze ## API-Endpunkte +### Zeiteinträge - `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen - `POST /api/entries` - Neuen Eintrag erstellen - `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren - `DELETE /api/entries/:id` - Eintrag löschen -- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren + +### Export +- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge als CSV exportieren +- `GET /api/export-deviations?from=YYYY-MM-DD&to=YYYY-MM-DD` - Nur Abweichungen als CSV exportieren + +### Einstellungen +- `GET /api/settings/:key` - Einstellung abrufen +- `POST /api/settings` - Einstellung speichern (key, value) +- `GET /api/settings` - Alle Einstellungen abrufen ## Installation & Ausführung @@ -144,13 +197,32 @@ http://localhost:3000 ## CSV-Export-Format -Die exportierte CSV-Datei enthält folgende Spalten: -- **Datum**: Datum im Format TT.MM.JJJJ -- **Startzeit**: Startzeit im Format HH:MM -- **Endzeit**: Endzeit im Format HH:MM -- **Pause in Minuten**: Pausenzeit in Minuten +Die Anwendung bietet zwei Export-Optionen: + +### 1. Vollständiger Export (📥 Button) +Exportiert alle Einträge im gewählten Zeitraum (oder alle, wenn kein Filter gesetzt). + +### 2. Export nur Abweichungen (⚠️ Button) +Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweichen. +- **Zweck**: Ideal für Arbeitszeitnachweise bei Gleitzeit-Modellen +- **Inhalt**: Nur Über- und Unterschreitungen der 8-Stunden-Marke +- **Vorteil**: Übersichtlicher Nachweis für HR/Verwaltung ohne irrelevante Standard-Tage + +### CSV-Spalten: +- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025) +- **Startzeit**: HH:MM (z.B. 08:00) +- **Endzeit**: HH:MM (z.B. 17:00) +- **Pause in Minuten**: Ganzzahl (z.B. 30) - **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50) +**Beispiel Abweichungs-Export:** +```csv +Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden +21.10.2025,08:00,18:30,45,9,75 +22.10.2025,09:00,15:30,30,6,00 +``` +(Tage mit exakt 8,0h werden nicht exportiert) + ## Entwicklung Die Anwendung verwendet: diff --git a/db/schema.sql b/db/schema.sql index b42d340..7abd936 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -6,3 +6,9 @@ CREATE TABLE IF NOT EXISTS entries ( pause_minutes INTEGER NOT NULL DEFAULT 0, location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')) ); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/public/app.js b/public/app.js index c9783a2..1f3d0ba 100644 --- a/public/app.js +++ b/public/app.js @@ -24,6 +24,9 @@ let displayMonth = new Date().getMonth(); // 0-11 let bulkEditMode = false; let selectedEntries = new Set(); +// Settings state +let currentBundesland = 'BW'; // Default: Baden-Württemberg + // ============================================ // UTILITY FUNCTIONS // ============================================ @@ -169,58 +172,103 @@ function getEasterSunday(year) { } /** - * Get all public holidays for Baden-Württemberg for a given year + * Get all public holidays for a given year and Bundesland */ -function getPublicHolidays(year) { +function getPublicHolidays(year, bundesland = currentBundesland) { const holidays = []; - // Fixed holidays + // Fixed holidays (all states) holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' }); - holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' }); holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' }); holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' }); - holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' }); holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' }); holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' }); + // Heilige Drei Könige (BW, BY, ST) + if (['BW', 'BY', 'ST'].includes(bundesland)) { + holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' }); + } + + // Internationaler Frauentag (BE, MV since 2023) + if (['BE'].includes(bundesland) || (bundesland === 'MV' && year >= 2023)) { + holidays.push({ date: new Date(year, 2, 8), name: 'Internationaler Frauentag' }); + } + + // Weltkindertag (TH since 2019) + if (bundesland === 'TH' && year >= 2019) { + holidays.push({ date: new Date(year, 8, 20), name: 'Weltkindertag' }); + } + + // Reformationstag (BB, MV, SN, ST, TH, + HB, HH, NI, SH since 2018) + const reformationstagStates = ['BB', 'MV', 'SN', 'ST', 'TH']; + if (year >= 2018) { + reformationstagStates.push('HB', 'HH', 'NI', 'SH'); + } + if (reformationstagStates.includes(bundesland)) { + holidays.push({ date: new Date(year, 9, 31), name: 'Reformationstag' }); + } + + // Allerheiligen (BW, BY, NW, RP, SL) + if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bundesland)) { + holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' }); + } + + // Buß- und Bettag (only SN) + if (bundesland === 'SN') { + // Buß- und Bettag is the Wednesday before November 23 + let bussbettag = new Date(year, 10, 23); + while (bussbettag.getDay() !== 3) { // 3 = Wednesday + bussbettag.setDate(bussbettag.getDate() - 1); + } + bussbettag.setDate(bussbettag.getDate() - 7); // One week before + holidays.push({ date: bussbettag, name: 'Buß- und Bettag' }); + } + // Easter-dependent holidays const easter = getEasterSunday(year); - // Karfreitag (Good Friday) - 2 days before Easter + // Karfreitag (Good Friday) - 2 days before Easter (all states) const goodFriday = new Date(easter); goodFriday.setDate(easter.getDate() - 2); holidays.push({ date: goodFriday, name: 'Karfreitag' }); - // Ostermontag (Easter Monday) - 1 day after Easter + // Ostermontag (Easter Monday) - 1 day after Easter (all states) const easterMonday = new Date(easter); easterMonday.setDate(easter.getDate() + 1); holidays.push({ date: easterMonday, name: 'Ostermontag' }); - // Christi Himmelfahrt (Ascension Day) - 39 days after Easter + // Christi Himmelfahrt (Ascension Day) - 39 days after Easter (all states) const ascension = new Date(easter); ascension.setDate(easter.getDate() + 39); holidays.push({ date: ascension, name: 'Christi Himmelfahrt' }); - // Pfingstmontag (Whit Monday) - 50 days after Easter + // Pfingstmontag (Whit Monday) - 50 days after Easter (all states) const whitMonday = new Date(easter); whitMonday.setDate(easter.getDate() + 50); holidays.push({ date: whitMonday, name: 'Pfingstmontag' }); - // Fronleichnam (Corpus Christi) - 60 days after Easter - const corpusChristi = new Date(easter); - corpusChristi.setDate(easter.getDate() + 60); - holidays.push({ date: corpusChristi, name: 'Fronleichnam' }); + // Fronleichnam (Corpus Christi) - 60 days after Easter (BW, BY, HE, NW, RP, SL, + some communities in SN, TH) + if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bundesland)) { + const corpusChristi = new Date(easter); + corpusChristi.setDate(easter.getDate() + 60); + holidays.push({ date: corpusChristi, name: 'Fronleichnam' }); + } + + // Mariä Himmelfahrt (Assumption of Mary) - August 15 (BY in some communities, SL) + if (['SL'].includes(bundesland)) { + holidays.push({ date: new Date(year, 7, 15), name: 'Mariä Himmelfahrt' }); + } return holidays; } /** - * Check if date is a public holiday in Baden-Württemberg + * Check if date is a public holiday * Returns the holiday name or null */ function getHolidayName(date) { const year = date.getFullYear(); - const holidays = getPublicHolidays(year); + const holidays = getPublicHolidays(year, currentBundesland); const dateStr = date.toISOString().split('T')[0]; const holiday = holidays.find(h => { @@ -610,6 +658,53 @@ async function deleteEntry(id) { } } +/** + * Get a setting by key + */ +async function getSetting(key) { + try { + const response = await fetch(`/api/settings/${key}`); + + if (!response.ok) { + if (response.status === 404) { + return null; // Setting doesn't exist yet + } + throw new Error('Failed to get setting'); + } + + const data = await response.json(); + return data.value; + } catch (error) { + console.error('Error getting setting:', error); + return null; + } +} + +/** + * Set a setting + */ +async function setSetting(key, value) { + try { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ key, value }) + }); + + if (!response.ok) { + throw new Error('Failed to set setting'); + } + + return true; + } catch (error) { + console.error('Error setting setting:', error); + showNotification('Fehler beim Speichern der Einstellung', 'error'); + return false; + } +} + /** * Export entries as CSV */ @@ -1502,6 +1597,107 @@ async function bulkDeleteEntries() { showNotification(`✓ ${deleted} Eintrag/Einträge gelöscht`, 'success'); } +// ============================================ +// BUNDESLAND / SETTINGS +// ============================================ + +/** + * Handle Bundesland change + */ +async function handleBundeslandChange(event) { + const newBundesland = event.target.value; + const oldBundesland = currentBundesland; + + // Check for conflicts with existing entries + const entries = await fetchEntries(); + const conflicts = []; + + // Get old and new holidays for comparison + const currentYear = new Date().getFullYear(); + const years = [currentYear - 1, currentYear, currentYear + 1]; + + const oldHolidays = new Set(); + const newHolidays = new Set(); + + years.forEach(year => { + getPublicHolidays(year, oldBundesland).forEach(h => { + oldHolidays.add(h.date.toISOString().split('T')[0]); + }); + getPublicHolidays(year, newBundesland).forEach(h => { + newHolidays.add(h.date.toISOString().split('T')[0]); + }); + }); + + // Find dates that are holidays in new state but not in old state, and have entries + entries.forEach(entry => { + if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) { + const dateObj = new Date(entry.date); + // Temporarily set to new bundesland to get holiday name + const tempBundesland = currentBundesland; + currentBundesland = newBundesland; + const holidayName = getHolidayName(dateObj); + currentBundesland = tempBundesland; + + conflicts.push({ + date: entry.date, + displayDate: formatDateDisplay(entry.date), + holidayName: holidayName + }); + } + }); + + // Warn user if conflicts exist + if (conflicts.length > 0) { + const conflictList = conflicts.map(c => ` • ${c.displayDate} (${c.holidayName})`).join('\n'); + const message = `⚠️ Achtung!\n\nDie folgenden Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nMöchten Sie fortfahren? Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.`; + + if (!confirm(message)) { + // Revert selection + event.target.value = oldBundesland; + return; + } + } + + // Update state and save + currentBundesland = newBundesland; + await setSetting('bundesland', newBundesland); + + // Reload view to show updated holidays + await loadMonthlyView(); + + const bundeslandNames = { + 'BW': 'Baden-Württemberg', + 'BY': 'Bayern', + 'BE': 'Berlin', + 'BB': 'Brandenburg', + 'HB': 'Bremen', + 'HH': 'Hamburg', + 'HE': 'Hessen', + 'MV': 'Mecklenburg-Vorpommern', + 'NI': 'Niedersachsen', + 'NW': 'Nordrhein-Westfalen', + 'RP': 'Rheinland-Pfalz', + 'SL': 'Saarland', + 'SN': 'Sachsen', + 'ST': 'Sachsen-Anhalt', + 'SH': 'Schleswig-Holstein', + 'TH': 'Thüringen' + }; + + showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success'); +} + +/** + * Load saved settings + */ +async function loadSettings() { + const savedBundesland = await getSetting('bundesland'); + if (savedBundesland) { + currentBundesland = savedBundesland; + document.getElementById('bundeslandSelect').value = savedBundesland; + } +} + // ============================================ // INLINE EDITING // ============================================ @@ -1862,6 +2058,9 @@ function initializeEventListeners() { document.getElementById('btnStartWork').addEventListener('click', startWork); document.getElementById('btnStopWork').addEventListener('click', stopWork); + // Bundesland selection + document.getElementById('bundeslandSelect').addEventListener('change', handleBundeslandChange); + // Close modal when clicking outside document.getElementById('entryModal').addEventListener('click', (e) => { if (e.target.id === 'entryModal') { @@ -1879,9 +2078,10 @@ function initializeEventListeners() { // INITIALIZATION // ============================================ -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { initializeFlatpickr(); initializeEventListeners(); + await loadSettings(); // Load saved settings first checkRunningTimer(); // Check if timer was running loadMonthlyView(); // Load monthly view by default }); diff --git a/public/index.html b/public/index.html index 6743d8a..95e5c14 100644 --- a/public/index.html +++ b/public/index.html @@ -193,6 +193,38 @@ + + +
+ + ⚙️ + Einstellungen + +
+
+ + +
+
+
diff --git a/server.js b/server.js index f064772..5d7017e 100644 --- a/server.js +++ b/server.js @@ -40,6 +40,20 @@ try { console.error('Error during migration:', error); } +// Create settings table if it doesn't exist +try { + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + console.log('Settings table ready'); +} catch (error) { + console.error('Error creating settings table:', error); +} + console.log('Database initialized successfully'); // ============================================ @@ -334,6 +348,71 @@ app.get('/api/export', (req, res) => { } }); +// ============================================ +// SETTINGS ENDPOINTS +// ============================================ + +// Get a setting by key +app.get('/api/settings/:key', (req, res) => { + try { + const { key } = req.params; + const stmt = db.prepare('SELECT value FROM settings WHERE key = ?'); + const result = stmt.get(key); + + if (!result) { + return res.status(404).json({ error: 'Setting not found' }); + } + + res.json({ key, value: result.value }); + } catch (error) { + console.error('Error getting setting:', error); + res.status(500).json({ error: 'Failed to get setting' }); + } +}); + +// Set a setting +app.post('/api/settings', (req, res) => { + try { + const { key, value } = req.body; + + if (!key || value === undefined) { + return res.status(400).json({ error: 'Key and value are required' }); + } + + const stmt = db.prepare(` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + `); + + stmt.run(key, value); + res.json({ key, value }); + } catch (error) { + console.error('Error setting setting:', error); + res.status(500).json({ error: 'Failed to set setting' }); + } +}); + +// Get all settings +app.get('/api/settings', (req, res) => { + try { + const stmt = db.prepare('SELECT key, value FROM settings'); + const settings = stmt.all(); + + const result = {}; + settings.forEach(s => { + result[s.key] = s.value; + }); + + res.json(result); + } catch (error) { + console.error('Error getting settings:', error); + res.status(500).json({ error: 'Failed to get settings' }); + } +}); + // Start server app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`);