Compare commits
11 Commits
bad91636b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f50e0fee7e | ||
|
|
a837a8af59 | ||
|
|
9408a13251 | ||
|
|
676dd2f497 | ||
|
|
432c3a0ccf | ||
|
|
9c25b47da1 | ||
|
|
995d1080f3 | ||
|
|
fe69bcb357 | ||
|
|
0b408c93ee | ||
|
|
0045a8f8d0 | ||
|
|
d04ab18ba1 |
@@ -13,9 +13,9 @@ on:
|
|||||||
- 'docker-compose.yml'
|
- 'docker-compose.yml'
|
||||||
- 'db/**'
|
- 'db/**'
|
||||||
- 'public/**'
|
- 'public/**'
|
||||||
|
- '.gitea/workflows/**'
|
||||||
- '!public/**/*.md'
|
- '!public/**/*.md'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
- '!.gitea/workflows/**'
|
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
@@ -29,9 +29,9 @@ on:
|
|||||||
- 'docker-compose.yml'
|
- 'docker-compose.yml'
|
||||||
- 'db/**'
|
- 'db/**'
|
||||||
- 'public/**'
|
- 'public/**'
|
||||||
|
- '.gitea/workflows/**'
|
||||||
- '!public/**/*.md'
|
- '!public/**/*.md'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
- '!.gitea/workflows/**'
|
|
||||||
- '!.gitignore'
|
- '!.gitignore'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -43,19 +43,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# - name: Cache Gitea Actions
|
|
||||||
# uses: actions/cache@v3
|
|
||||||
# with:
|
|
||||||
# path: |
|
|
||||||
# ~/.cache/actcache
|
|
||||||
# ~/.cache/act
|
|
||||||
# key: ${{ runner.os }}-act-${{ hashFiles('**/.gitea/workflows/*.yml') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-act-
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -76,18 +69,19 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
COMMIT_HASH=${{ github.sha }}
|
COMMIT_HASH=${{ github.sha }}
|
||||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
cache-to: type=inline
|
||||||
|
|
||||||
- name: Image digest
|
- name: Image digest
|
||||||
run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}"
|
run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}"
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -25,6 +25,14 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
- Timer persistiert über Seiten-Reloads
|
- Timer persistiert über Seiten-Reloads
|
||||||
- Manuelle Startzeit-Eingabe möglich
|
- Manuelle Startzeit-Eingabe möglich
|
||||||
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
|
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
|
||||||
|
- **Timer-Metriken** bei laufendem Timer:
|
||||||
|
- Läuft seit: Startzeit mit Icon-Styling
|
||||||
|
- Soll erreicht: Uhrzeit wann Tagesziel erreicht wird (inkl. Pausen)
|
||||||
|
- Zeit bis Soll: Live-Countdown zur Zielzeit
|
||||||
|
- Saldo bei Soll: Prognostizierter Gesamtsaldo nach Erreichen der geplanten Zeit
|
||||||
|
- **Anpassbare Arbeitszeit**: Dropdown 4h-10h für flexible Arbeitstage
|
||||||
|
- Einstellung bleibt über Reloads erhalten (nur während Timer läuft)
|
||||||
|
- Wird bei Timer-Stop auf 8h zurückgesetzt
|
||||||
- **Flexible Eingabemodi**:
|
- **Flexible Eingabemodi**:
|
||||||
- Manuelle Eingabe (Datum, Start, Ende, Pause)
|
- Manuelle Eingabe (Datum, Start, Ende, Pause)
|
||||||
- Inline-Bearbeitung direkt in der Tabelle
|
- Inline-Bearbeitung direkt in der Tabelle
|
||||||
@@ -38,10 +46,16 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
|
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
|
||||||
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
|
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
|
||||||
- **Live-Statistiken** mit laufendem Timer:
|
- **Live-Statistiken** mit laufendem Timer:
|
||||||
- Soll-Stunden (basierend auf Arbeitstagen)
|
- Soll-Stunden (basierend auf Arbeitstagen mit Einträgen)
|
||||||
- Ist-Stunden (inkl. aktuell laufender Timer)
|
- Ist-Stunden (inkl. aktuell laufender Timer)
|
||||||
- Monatssaldo + Gesamtsaldo mit Vormonatsübertrag
|
- Monatssaldo + Gesamtsaldo mit Vormonatsübertrag
|
||||||
- Arbeitstage-Zählung
|
- Arbeitstage-Zählung
|
||||||
|
- **Intelligente Soll-Berechnung**: Berücksichtigt nur Tage mit Einträgen oder laufendem Timer
|
||||||
|
- **Laufendes Saldo** in Monatsansicht:
|
||||||
|
- Spalte "Saldo" zeigt kumulatives Saldo bis zu jedem Tag
|
||||||
|
- Live-Updates während Timer läuft
|
||||||
|
- Farbcodierung: Grün (positiv) / Rot (negativ)
|
||||||
|
- Berücksichtigt Flextime-Tage korrekt (-8h)
|
||||||
- **Urlaubsverwaltung**:
|
- **Urlaubsverwaltung**:
|
||||||
- Konfigurierbares Jahres-Kontingent
|
- Konfigurierbares Jahres-Kontingent
|
||||||
- Tracking: Genommen, Geplant, Verfügbar
|
- Tracking: Genommen, Geplant, Verfügbar
|
||||||
@@ -50,6 +64,10 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
### 🗓️ Bundesland-spezifische Feiertage
|
### 🗓️ Bundesland-spezifische Feiertage
|
||||||
- **16 Bundesländer** mit korrekten regionalen Feiertagen
|
- **16 Bundesländer** mit korrekten regionalen Feiertagen
|
||||||
- **Persistente Einstellung** (gespeichert in Datenbank)
|
- **Persistente Einstellung** (gespeichert in Datenbank)
|
||||||
|
- **Betriebsfreie Tage**: Wählbar zwischen Heiligabend (24.12.) oder Silvester (31.12.)
|
||||||
|
- Toggle in Einstellungen
|
||||||
|
- Wird als "Betriebsfrei" markiert
|
||||||
|
- Verhindert doppelte Einträge an diesen Tagen
|
||||||
- **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
|
- **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
|
||||||
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
|
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
|
||||||
|
|
||||||
@@ -62,6 +80,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
- 🔴 Rot: Fehlende Arbeitstage
|
- 🔴 Rot: Fehlende Arbeitstage
|
||||||
- ⚫ Grau: Wochenenden
|
- ⚫ Grau: Wochenenden
|
||||||
- 🔵 Blau: Feiertage (mit Namen)
|
- 🔵 Blau: Feiertage (mit Namen)
|
||||||
|
- 💙 Blauer Rand: Heutiger Tag
|
||||||
- **Navigation**: Vor/Zurück-Buttons zum Monatswechsel
|
- **Navigation**: Vor/Zurück-Buttons zum Monatswechsel
|
||||||
- **Auto-Fill**: Automatisches Befüllen des Monats mit Standard-Arbeitszeiten (9:00-17:30)
|
- **Auto-Fill**: Automatisches Befüllen des Monats mit Standard-Arbeitszeiten (9:00-17:30)
|
||||||
- **Quick-Actions**: Plus-Buttons für schnelles Hinzufügen von Einträgen
|
- **Quick-Actions**: Plus-Buttons für schnelles Hinzufügen von Einträgen
|
||||||
@@ -83,7 +102,9 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
|
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
|
||||||
- Ideal für Gleitzeit-Nachweise
|
- Ideal für Gleitzeit-Nachweise
|
||||||
- **PDF-Export**:
|
- **PDF-Export**:
|
||||||
- **Monats-Export**: Exportiert aktuellen Monat als formatierten PDF
|
- **Monats-Export**: Nur verfügbar wenn letzter Tag des Monats vollständig erfasst ist
|
||||||
|
- Verhindert versehentliche Exports unvollständiger Monate
|
||||||
|
- Button wird automatisch angezeigt sobald Bedingung erfüllt
|
||||||
- **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
|
- **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
|
||||||
- Professionelles Layout mit Mitarbeiter-Info und Statistiken
|
- Professionelles Layout mit Mitarbeiter-Info und Statistiken
|
||||||
- Automatische Tabelle mit allen Einträgen
|
- Automatische Tabelle mit allen Einträgen
|
||||||
@@ -105,6 +126,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
### 🎨 Modernes UI/UX
|
### 🎨 Modernes UI/UX
|
||||||
- **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen
|
- **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen
|
||||||
- **Responsive**: Desktop, Tablet, Mobile
|
- **Responsive**: Desktop, Tablet, Mobile
|
||||||
|
- Timer-Metriken: Rechts neben Timer auf großen Displays, darunter auf mobil
|
||||||
|
- Adaptive Layouts für alle Bildschirmgrößen
|
||||||
- **Dark Mode**: Augenschonendes dunkles Design
|
- **Dark Mode**: Augenschonendes dunkles Design
|
||||||
- **Toast-Benachrichtigungen**: Visuelles Feedback
|
- **Toast-Benachrichtigungen**: Visuelles Feedback
|
||||||
- **Icons**: Lucide Icons für klare Symbolik
|
- **Icons**: Lucide Icons für klare Symbolik
|
||||||
@@ -113,7 +136,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
## 🏗️ Technologie-Stack
|
## 🏗️ Technologie-Stack
|
||||||
|
|
||||||
**Backend:**
|
**Backend:**
|
||||||
- Node.js 18+ mit Express.js
|
- Node.js 20+ mit Express.js
|
||||||
- SQLite (better-sqlite3) für dateibasierte Persistenz
|
- SQLite (better-sqlite3) für dateibasierte Persistenz
|
||||||
- Modulare Architektur (config, utils, routes)
|
- Modulare Architektur (config, utils, routes)
|
||||||
|
|
||||||
@@ -127,6 +150,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
|||||||
**Infrastructure:**
|
**Infrastructure:**
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
- Multi-Stage Build für optimierte Images
|
- Multi-Stage Build für optimierte Images
|
||||||
|
- Node.js 20 Alpine Linux base image
|
||||||
|
- Python build dependencies für native Module
|
||||||
- Gitea Actions CI/CD für automatische Builds
|
- Gitea Actions CI/CD für automatische Builds
|
||||||
- Gitea Container Registry für Image-Hosting
|
- Gitea Container Registry für Image-Hosting
|
||||||
|
|
||||||
@@ -142,15 +167,16 @@ timetracker/
|
|||||||
│ ├── index.html # Single-Page Application
|
│ ├── index.html # Single-Page Application
|
||||||
│ ├── favicon.svg # App Icon
|
│ ├── favicon.svg # App Icon
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── state.js # Globaler State
|
│ ├── state.js # Globaler State (companyHolidayPreference, targetHours)
|
||||||
│ ├── utils.js # Hilfsfunktionen
|
│ ├── utils.js # Hilfsfunktionen
|
||||||
│ ├── holidays.js # Feiertagsberechnung
|
│ ├── holidays.js # Feiertagsberechnung (16 Bundesländer)
|
||||||
|
│ ├── bridge-days.js # Brückentag-Optimierung
|
||||||
│ ├── api.js # Backend-Kommunikation
|
│ ├── api.js # Backend-Kommunikation
|
||||||
│ └── main.js # Hauptlogik (~3700 Zeilen)
|
│ └── main.js # Hauptlogik (~4000 Zeilen)
|
||||||
├── .gitea/workflows/ # CI/CD Workflows
|
├── .gitea/workflows/ # CI/CD Workflows
|
||||||
│ └── docker-build.yml # Docker Build & Push
|
│ └── docker-build.yml # Docker Build & Push
|
||||||
├── media/screenshots/ # App-Screenshots
|
├── media/screenshots/ # App-Screenshots
|
||||||
├── Dockerfile # Container-Image
|
├── Dockerfile # Container-Image (Node 20 + Python)
|
||||||
├── docker-compose.yml # Orchestrierung
|
├── docker-compose.yml # Orchestrierung
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
@@ -227,7 +253,7 @@ docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
|
|||||||
|
|
||||||
### 💻 Option 3: Lokal (ohne Docker)
|
### 💻 Option 3: Lokal (ohne Docker)
|
||||||
|
|
||||||
**Voraussetzungen:** Node.js 18+
|
**Voraussetzungen:** Node.js 20+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Repository klonen
|
# Repository klonen
|
||||||
@@ -247,6 +273,9 @@ Die App bietet mehrere Export-Modi für verschiedene Anwendungsfälle:
|
|||||||
### PDF-Export 📄
|
### PDF-Export 📄
|
||||||
|
|
||||||
**Monats-Export:**
|
**Monats-Export:**
|
||||||
|
- Button erscheint nur wenn letzter Tag des Monats vollständig erfasst ist
|
||||||
|
- Startzeit und Endzeit müssen vorhanden sein
|
||||||
|
- Verhindert versehentliche Exports unvollständiger Monate
|
||||||
- Klicke auf "PDF Export" in der Monatsansicht
|
- Klicke auf "PDF Export" in der Monatsansicht
|
||||||
- Exportiert alle Einträge des aktuellen Monats
|
- Exportiert alle Einträge des aktuellen Monats
|
||||||
- Professionelles Layout mit:
|
- Professionelles Layout mit:
|
||||||
@@ -362,13 +391,14 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
|
|||||||
**Architektur:** Single-Page Application (SPA) mit REST-API Backend
|
**Architektur:** Single-Page Application (SPA) mit REST-API Backend
|
||||||
|
|
||||||
**Tech-Details:**
|
**Tech-Details:**
|
||||||
- Modulare Frontend-Architektur (5 separate JS-Dateien)
|
- Modulare Frontend-Architektur (6 separate JS-Dateien)
|
||||||
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
|
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
|
||||||
- Lucide Icons für Symbolik
|
- Lucide Icons für Symbolik
|
||||||
- jsPDF + autoTable für PDF-Generierung
|
- jsPDF + autoTable für PDF-Generierung
|
||||||
- SQLite für dateibasierte Persistenz
|
- SQLite für dateibasierte Persistenz
|
||||||
- Server-seitige Berechnungen für Datenintegrität
|
- Server-seitige Berechnungen für Datenintegrität
|
||||||
- Responsive Design (Tailwind CSS via CDN)
|
- Responsive Design (Tailwind CSS via CDN)
|
||||||
|
- Live-Updates während Timer läuft (Saldo, Nettostunden)
|
||||||
|
|
||||||
**Datenpersistenz:**
|
**Datenpersistenz:**
|
||||||
- SQLite-Datenbank: `db/timetracker.db`
|
- SQLite-Datenbank: `db/timetracker.db`
|
||||||
@@ -377,11 +407,20 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
|
|||||||
- JSON-basierte Backups für Migration
|
- JSON-basierte Backups für Migration
|
||||||
|
|
||||||
**Code-Organisation:**
|
**Code-Organisation:**
|
||||||
- `state.js`: Globaler Application State
|
- `state.js`: Globaler Application State (companyHolidayPreference, targetHours)
|
||||||
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
|
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
|
||||||
- `holidays.js`: Feiertagsberechnung (16 Bundesländer)
|
- `holidays.js`: Feiertagsberechnung (16 Bundesländer + Betriebsfrei)
|
||||||
|
- `bridge-days.js`: Brückentag-Optimierung
|
||||||
- `api.js`: Backend-Kommunikation
|
- `api.js`: Backend-Kommunikation
|
||||||
- `main.js`: Hauptlogik, UI, Event-Handler
|
- `main.js`: Hauptlogik, UI, Event-Handler (~4000 Zeilen)
|
||||||
|
|
||||||
|
**Neue Features:**
|
||||||
|
- Timer-Metriken mit Live-Berechnung
|
||||||
|
- Anpassbare tägliche Arbeitszeit (4h-10h)
|
||||||
|
- Laufendes Saldo in Tabelle
|
||||||
|
- Intelligente PDF-Export-Aktivierung
|
||||||
|
- Betriebsfreier Tag (Weihnachten/Silvester)
|
||||||
|
- Responsive Timer-Layout
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
|
|||||||
@@ -491,6 +491,9 @@
|
|||||||
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
||||||
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Arbeitstage</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Arbeitstage</div>
|
||||||
<div id="statWorkdays" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-400">0</div>
|
<div id="statWorkdays" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-400">0</div>
|
||||||
|
<div id="statSickdays" class="text-xs text-red-400 mt-2 hidden">
|
||||||
|
<i data-lucide="activity" class="w-3 h-3 inline"></i> <span id="statSickdaysCount">0</span> Krankheitstage
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -401,15 +401,19 @@ function updateTimerMetrics(netElapsedSeconds) {
|
|||||||
// Target hours from user selection (default 8)
|
// Target hours from user selection (default 8)
|
||||||
const targetSeconds = targetHours * 60 * 60;
|
const targetSeconds = targetHours * 60 * 60;
|
||||||
|
|
||||||
// Calculate total pause time: 30 min after 6h + 15 min after 9h
|
// Calculate required pause time based on target hours (German law)
|
||||||
const pauseDuration30Min = 30 * 60; // 30 minutes in seconds
|
let totalPauseSeconds = 0;
|
||||||
const pauseDuration15Min = 15 * 60; // 15 minutes in seconds
|
if (targetHours > 9) {
|
||||||
|
// More than 9h -> 45 min pause (30 + 15)
|
||||||
|
totalPauseSeconds = 45 * 60;
|
||||||
|
} else if (targetHours > 6) {
|
||||||
|
// More than 6h -> 30 min pause
|
||||||
|
totalPauseSeconds = 30 * 60;
|
||||||
|
}
|
||||||
|
// 6h or less -> no mandatory pause
|
||||||
|
|
||||||
// Time needed including pauses
|
// Total gross time = target work hours + pause time
|
||||||
// After 6h work -> 30 min pause
|
const totalGrossTimeNeeded = targetSeconds + totalPauseSeconds;
|
||||||
// After 9h work -> 15 min pause
|
|
||||||
// Total gross time = target work hours + 30min pause + 15min pause
|
|
||||||
const totalGrossTimeNeeded = targetSeconds + pauseDuration30Min + pauseDuration15Min;
|
|
||||||
|
|
||||||
// Calculate when target will be reached (clock time)
|
// Calculate when target will be reached (clock time)
|
||||||
const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000);
|
const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000);
|
||||||
@@ -647,11 +651,11 @@ function renderEntries(entries) {
|
|||||||
</td>
|
</td>
|
||||||
` : '<td class="hidden"></td>';
|
` : '<td class="hidden"></td>';
|
||||||
|
|
||||||
// Calculate balance: for work and flextime entries (excluding vacation)
|
// Calculate balance: for work and flextime entries (excluding vacation and sickday)
|
||||||
const entryType = entry.entryType || 'work';
|
const entryType = entry.entryType || 'work';
|
||||||
let balanceCell = '';
|
let balanceCell = '';
|
||||||
|
|
||||||
if (entryType !== 'vacation') {
|
if (entryType !== 'vacation' && entryType !== 'sickday') {
|
||||||
// For all workdays (including flextime): Actual - Target (8h)
|
// For all workdays (including flextime): Actual - Target (8h)
|
||||||
// Flextime has netHours = 0, so deviation will be -8h
|
// Flextime has netHours = 0, so deviation will be -8h
|
||||||
const deviation = entry.netHours - 8.0;
|
const deviation = entry.netHours - 8.0;
|
||||||
@@ -804,6 +808,12 @@ function renderMonthlyView(entries) {
|
|||||||
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
|
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
|
||||||
<span class="italic">Urlaub</span>
|
<span class="italic">Urlaub</span>
|
||||||
</td>`;
|
</td>`;
|
||||||
|
} else if (entryType === 'sickday') {
|
||||||
|
displayIcon = '<i data-lucide="activity" class="w-4 h-4 inline"></i>';
|
||||||
|
displayText = 'Krank';
|
||||||
|
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
|
||||||
|
<span class="italic">Krankheit</span>
|
||||||
|
</td>`;
|
||||||
} else if (entryType === 'flextime') {
|
} else if (entryType === 'flextime') {
|
||||||
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
|
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
|
||||||
displayText = 'Gleitzeit';
|
displayText = 'Gleitzeit';
|
||||||
@@ -845,11 +855,11 @@ function renderMonthlyView(entries) {
|
|||||||
</td>
|
</td>
|
||||||
` : '<td class="hidden"></td>';
|
` : '<td class="hidden"></td>';
|
||||||
|
|
||||||
// Calculate balance: only for past days (excluding vacation)
|
// Calculate balance: only for past days (excluding vacation and sickday)
|
||||||
const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay);
|
const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay);
|
||||||
let balanceCell = '';
|
let balanceCell = '';
|
||||||
|
|
||||||
if (isPastDay && entryType !== 'vacation') {
|
if (isPastDay && entryType !== 'vacation' && entryType !== 'sickday') {
|
||||||
// For all workdays (including flextime): Actual - Target (8h default)
|
// For all workdays (including flextime): Actual - Target (8h default)
|
||||||
// Flextime has netHours = 0, so deviation will be -8h
|
// Flextime has netHours = 0, so deviation will be -8h
|
||||||
|
|
||||||
@@ -938,6 +948,9 @@ function renderMonthlyView(entries) {
|
|||||||
<button class="btn-add-vacation inline-flex items-center justify-center w-7 h-7 text-amber-400/80 hover:text-amber-400 hover:bg-amber-500/10 rounded transition-all" data-date="${dateISO}" title="Urlaub eintragen">
|
<button class="btn-add-vacation inline-flex items-center justify-center w-7 h-7 text-amber-400/80 hover:text-amber-400 hover:bg-amber-500/10 rounded transition-all" data-date="${dateISO}" title="Urlaub eintragen">
|
||||||
<i data-lucide="plane" class="w-4 h-4"></i>
|
<i data-lucide="plane" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-add-sickday inline-flex items-center justify-center w-7 h-7 text-red-400/80 hover:text-red-400 hover:bg-red-500/10 rounded transition-all" data-date="${dateISO}" title="Krankheit eintragen">
|
||||||
|
<i data-lucide="activity" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
<button class="btn-add-flextime inline-flex items-center justify-center w-7 h-7 text-cyan-400/80 hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-all" data-date="${dateISO}" title="Gleitzeit eintragen">
|
<button class="btn-add-flextime inline-flex items-center justify-center w-7 h-7 text-cyan-400/80 hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-all" data-date="${dateISO}" title="Gleitzeit eintragen">
|
||||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -989,6 +1002,14 @@ function renderMonthlyView(entries) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add sick day entry for a specific date
|
||||||
|
document.querySelectorAll('.btn-add-sickday').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const dateISO = btn.dataset.date;
|
||||||
|
await addSpecialEntry(dateISO, 'sickday');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Add flextime entry for a specific date
|
// Add flextime entry for a specific date
|
||||||
document.querySelectorAll('.btn-add-flextime').forEach(btn => {
|
document.querySelectorAll('.btn-add-flextime').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -1016,7 +1037,7 @@ function renderMonthlyView(entries) {
|
|||||||
* Add a special entry (vacation or flextime) for a specific date
|
* Add a special entry (vacation or flextime) for a specific date
|
||||||
*/
|
*/
|
||||||
async function addSpecialEntry(dateISO, entryType) {
|
async function addSpecialEntry(dateISO, entryType) {
|
||||||
const typeName = entryType === 'vacation' ? 'Urlaub' : 'Gleittag';
|
const typeName = entryType === 'vacation' ? 'Urlaub' : entryType === 'sickday' ? 'Krankheit' : 'Gleittag';
|
||||||
|
|
||||||
// Check if date is a public holiday
|
// Check if date is a public holiday
|
||||||
const dateObj = new Date(dateISO + 'T00:00:00');
|
const dateObj = new Date(dateISO + 'T00:00:00');
|
||||||
@@ -1046,7 +1067,9 @@ async function addSpecialEntry(dateISO, entryType) {
|
|||||||
throw new Error(error.error || 'Fehler beim Erstellen');
|
throw new Error(error.error || 'Fehler beim Erstellen');
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' : '✅ Gleittag eingetragen (8h Soll, 0h Ist)';
|
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' :
|
||||||
|
entryType === 'sickday' ? '✅ Krankheit eingetragen (kein Arbeitstag)' :
|
||||||
|
'✅ Gleittag eingetragen (8h Soll, 0h Ist)';
|
||||||
showNotification(message, 'success');
|
showNotification(message, 'success');
|
||||||
loadMonthlyView();
|
loadMonthlyView();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1082,20 +1105,19 @@ async function loadMonthlyView() {
|
|||||||
updateStatistics(entries);
|
updateStatistics(entries);
|
||||||
updateBridgeDaysDisplay();
|
updateBridgeDaysDisplay();
|
||||||
|
|
||||||
// Show/hide PDF export button based on whether last day has complete times
|
// Show/hide PDF export button based on whether the month is complete (in the past)
|
||||||
const pdfButton = document.getElementById('btnExportPDF');
|
const pdfButton = document.getElementById('btnExportPDF');
|
||||||
const pdfButtonMobile = document.getElementById('btnExportPDFMobile');
|
const pdfButtonMobile = document.getElementById('btnExportPDFMobile');
|
||||||
|
|
||||||
// Check if last day of month has start and end time (and they're different)
|
// Check if the displayed month is in the past (not current month or future)
|
||||||
const lastDayDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
const today = new Date();
|
||||||
const lastDayEntry = entries.find(e => e.date === lastDayDate);
|
const currentYear = today.getFullYear();
|
||||||
|
const currentMonth = today.getMonth();
|
||||||
|
|
||||||
const isLastDayComplete = lastDayEntry &&
|
const isMonthComplete = (displayYear < currentYear) ||
|
||||||
lastDayEntry.startTime &&
|
(displayYear === currentYear && displayMonth < currentMonth);
|
||||||
lastDayEntry.endTime &&
|
|
||||||
lastDayEntry.startTime !== lastDayEntry.endTime;
|
|
||||||
|
|
||||||
if (isLastDayComplete) {
|
if (isMonthComplete) {
|
||||||
pdfButton.style.display = '';
|
pdfButton.style.display = '';
|
||||||
if (pdfButtonMobile) pdfButtonMobile.style.display = '';
|
if (pdfButtonMobile) pdfButtonMobile.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -1153,6 +1175,13 @@ async function updateStatistics(entries) {
|
|||||||
.map(e => e.date)
|
.map(e => e.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count sick days to exclude from workdays
|
||||||
|
const sickDays = new Set(
|
||||||
|
entries
|
||||||
|
.filter(e => e.entryType === 'sickday')
|
||||||
|
.map(e => e.date)
|
||||||
|
);
|
||||||
|
|
||||||
// Count flextime days (they are workdays with 0 hours worked)
|
// Count flextime days (they are workdays with 0 hours worked)
|
||||||
const flextimeDays = new Set(
|
const flextimeDays = new Set(
|
||||||
entries
|
entries
|
||||||
@@ -1162,6 +1191,15 @@ async function updateStatistics(entries) {
|
|||||||
|
|
||||||
let futureFlextimeDays = 0; // Count future flextime days in current month
|
let futureFlextimeDays = 0; // Count future flextime days in current month
|
||||||
|
|
||||||
|
// Create a map to check if a day has an entry
|
||||||
|
const entriesMap = {};
|
||||||
|
entries.forEach(entry => {
|
||||||
|
entriesMap[entry.date] = entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
const todayISO = getTodayISO();
|
||||||
|
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
||||||
|
|
||||||
for (let day = 1; day <= lastDay; day++) {
|
for (let day = 1; day <= lastDay; day++) {
|
||||||
const dateObj = new Date(currentYear, currentMonth, day);
|
const dateObj = new Date(currentYear, currentMonth, day);
|
||||||
const year = dateObj.getFullYear();
|
const year = dateObj.getFullYear();
|
||||||
@@ -1170,37 +1208,42 @@ async function updateStatistics(entries) {
|
|||||||
const dateISO = `${year}-${month}-${dayStr}`;
|
const dateISO = `${year}-${month}-${dayStr}`;
|
||||||
|
|
||||||
const isVacation = vacationDays.has(dateISO);
|
const isVacation = vacationDays.has(dateISO);
|
||||||
|
const isSickday = sickDays.has(dateISO);
|
||||||
const isFlextime = flextimeDays.has(dateISO);
|
const isFlextime = flextimeDays.has(dateISO);
|
||||||
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
||||||
|
const hasEntry = entriesMap[dateISO];
|
||||||
|
const isToday = dateISO === todayISO;
|
||||||
|
|
||||||
if (!isWeekendHoliday && !isVacation && !isFlextime) {
|
// For today: only count as workday if there's an entry OR timer is running
|
||||||
// Normal workday (excluding vacation and flextime days)
|
const shouldCountToday = !isToday || hasEntry || (timerStartTime && isCurrentMonth);
|
||||||
|
|
||||||
|
if (!isWeekendHoliday && !isVacation && !isSickday && !isFlextime) {
|
||||||
|
// Normal workday (excluding vacation, sick days, and flextime days)
|
||||||
totalWorkdaysInMonth++;
|
totalWorkdaysInMonth++;
|
||||||
workdaysCount++;
|
workdaysCount++;
|
||||||
if (dateObj <= today) {
|
if (dateObj <= today && shouldCountToday) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
}
|
}
|
||||||
} else if (!isWeekendHoliday && !isVacation && isFlextime) {
|
} else if (!isWeekendHoliday && !isVacation && !isSickday && isFlextime) {
|
||||||
// Flextime on a workday - still counts as workday in calendar
|
// Flextime on a workday - still counts as workday in calendar
|
||||||
totalWorkdaysInMonth++;
|
totalWorkdaysInMonth++;
|
||||||
workdaysCount++;
|
workdaysCount++;
|
||||||
if (dateObj <= today) {
|
if (dateObj <= today && shouldCountToday) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
} else {
|
} else {
|
||||||
// Future flextime in current month
|
// Future flextime in current month
|
||||||
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
if (isCurrentMonth && dateObj > today) {
|
||||||
if (isCurrentMonth) {
|
|
||||||
futureFlextimeDays++;
|
futureFlextimeDays++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isFlextime && isWeekendHoliday) {
|
} else if (isFlextime && isWeekendHoliday) {
|
||||||
// Flextime on weekend/holiday counts as additional workday
|
// Flextime on weekend/holiday counts as additional workday
|
||||||
totalWorkdaysInMonth++;
|
totalWorkdaysInMonth++;
|
||||||
if (new Date(dateISO) <= today) {
|
if (new Date(dateISO) <= today && shouldCountToday) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Vacation days are excluded from all counts
|
// Vacation days and sick days are excluded from all counts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate target hours (8h per workday passed, no reduction)
|
// Calculate target hours (8h per workday passed, no reduction)
|
||||||
@@ -1212,7 +1255,6 @@ async function updateStatistics(entries) {
|
|||||||
.reduce((sum, entry) => sum + entry.netHours, 0);
|
.reduce((sum, entry) => sum + entry.netHours, 0);
|
||||||
|
|
||||||
// Add currently running timer hours to actual hours (only for current month)
|
// Add currently running timer hours to actual hours (only for current month)
|
||||||
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
|
||||||
if (timerStartTime && isCurrentMonth) {
|
if (timerStartTime && isCurrentMonth) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let elapsedSeconds;
|
let elapsedSeconds;
|
||||||
@@ -1237,20 +1279,40 @@ async function updateStatistics(entries) {
|
|||||||
// Calculate previous month balance
|
// Calculate previous month balance
|
||||||
const previousBalance = await calculatePreviousBalance();
|
const previousBalance = await calculatePreviousBalance();
|
||||||
|
|
||||||
// Total balance = previous balance + current month balance
|
// Total balance = all months from first entry up to today (independent of displayed month)
|
||||||
const totalBalance = previousBalance + balance;
|
const totalBalance = await calculateTotalBalance();
|
||||||
|
|
||||||
// Count actual work entries (excluding vacation/flextime, only up to today)
|
// Count actual work entries (excluding vacation/flextime/sickday, only up to today)
|
||||||
const workEntriesCount = entries.filter(e => {
|
const workEntriesCount = entries.filter(e => {
|
||||||
const entryDate = new Date(e.date);
|
const entryDate = new Date(e.date);
|
||||||
return entryDate <= today && (!e.entryType || e.entryType === 'work');
|
return entryDate <= today && (!e.entryType || e.entryType === 'work');
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
|
// Count sick days for current month (only past days)
|
||||||
|
const sickDaysCount = entries.filter(e => {
|
||||||
|
const entryDate = new Date(e.date);
|
||||||
|
return entryDate <= today && e.entryType === 'sickday';
|
||||||
|
}).length;
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
|
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
|
||||||
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
|
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
|
||||||
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`;
|
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`;
|
||||||
|
|
||||||
|
// Show/hide sick days info
|
||||||
|
const sickdaysElement = document.getElementById('statSickdays');
|
||||||
|
const sickdaysCountElement = document.getElementById('statSickdaysCount');
|
||||||
|
if (sickDaysCount > 0) {
|
||||||
|
sickdaysCountElement.textContent = sickDaysCount;
|
||||||
|
sickdaysElement.classList.remove('hidden');
|
||||||
|
// Re-initialize icons for the activity icon
|
||||||
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sickdaysElement.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Show/hide flextime hint icons
|
// Show/hide flextime hint icons
|
||||||
const balanceFlextimeHint = document.getElementById('balanceFlextimeHint');
|
const balanceFlextimeHint = document.getElementById('balanceFlextimeHint');
|
||||||
const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint');
|
const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint');
|
||||||
@@ -1451,10 +1513,11 @@ async function addBridgeDaysAsVacation(days) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate balance from all previous months (starting from first month with entries)
|
* Calculate balance from all previous months (starting from first month with entries)
|
||||||
|
* up to (but not including) the given month. Defaults to the currently displayed month.
|
||||||
*/
|
*/
|
||||||
async function calculatePreviousBalance() {
|
async function calculatePreviousBalance(upToYear, upToMonth) {
|
||||||
const currentYear = displayYear;
|
const currentYear = (upToYear !== undefined) ? upToYear : displayYear;
|
||||||
const currentMonth = displayMonth;
|
const currentMonth = (upToMonth !== undefined) ? upToMonth : displayMonth;
|
||||||
|
|
||||||
// Find the first month with any entries by checking all entries
|
// Find the first month with any entries by checking all entries
|
||||||
const allEntries = await fetchEntries();
|
const allEntries = await fetchEntries();
|
||||||
@@ -1462,9 +1525,16 @@ async function calculatePreviousBalance() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out entries with invalid dates
|
||||||
|
const validEntries = allEntries.filter(e => e.date && e.date.match(/^\d{4}-\d{2}-\d{2}$/));
|
||||||
|
|
||||||
|
if (validEntries.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Find earliest date
|
// Find earliest date
|
||||||
const earliestDate = allEntries.reduce((earliest, entry) => {
|
const earliestDate = validEntries.reduce((earliest, entry) => {
|
||||||
const entryDate = new Date(entry.date);
|
const entryDate = new Date(entry.date + 'T00:00:00');
|
||||||
return !earliest || entryDate < earliest ? entryDate : earliest;
|
return !earliest || entryDate < earliest ? entryDate : earliest;
|
||||||
}, null);
|
}, null);
|
||||||
|
|
||||||
@@ -1481,6 +1551,9 @@ async function calculatePreviousBalance() {
|
|||||||
let checkMonth = firstMonth;
|
let checkMonth = firstMonth;
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0); // Normalize to start of day
|
||||||
|
|
||||||
|
console.log(`calculatePreviousBalance: Calculating from ${firstYear}-${firstMonth+1} to ${currentYear}-${currentMonth} (exclusive)`);
|
||||||
|
|
||||||
// Loop through all months from first entry until previous month of displayed month
|
// Loop through all months from first entry until previous month of displayed month
|
||||||
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
|
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
|
||||||
@@ -1490,9 +1563,9 @@ async function calculatePreviousBalance() {
|
|||||||
|
|
||||||
const entries = await fetchEntries(firstDay, lastDayStr);
|
const entries = await fetchEntries(firstDay, lastDayStr);
|
||||||
|
|
||||||
// For past months, use full month. For current month (if displayed), limit to today
|
// For past months (completed months), count ALL workdays
|
||||||
const monthEnd = new Date(checkYear, checkMonth + 1, 0);
|
// Only limit to today if this is the current calendar month
|
||||||
const limitDate = monthEnd; // Always use full month for previous balance calculation
|
const isCurrentCalendarMonth = (checkYear === today.getFullYear() && checkMonth === today.getMonth());
|
||||||
|
|
||||||
let workdaysPassed = 0;
|
let workdaysPassed = 0;
|
||||||
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
|
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
|
||||||
@@ -1504,6 +1577,13 @@ async function calculatePreviousBalance() {
|
|||||||
.map(e => e.date)
|
.map(e => e.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count sick days to exclude from workdays
|
||||||
|
const sickDays = new Set(
|
||||||
|
entries
|
||||||
|
.filter(e => e.entryType === 'sickday')
|
||||||
|
.map(e => e.date)
|
||||||
|
);
|
||||||
|
|
||||||
// Count flextime days (they are workdays with 0 hours worked)
|
// Count flextime days (they are workdays with 0 hours worked)
|
||||||
const flextimeDays = new Set(
|
const flextimeDays = new Set(
|
||||||
entries
|
entries
|
||||||
@@ -1511,16 +1591,24 @@ async function calculatePreviousBalance() {
|
|||||||
.map(e => e.date)
|
.map(e => e.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count workdays (all days for past months, up to today for current month)
|
||||||
for (let day = 1; day <= monthLastDay; day++) {
|
for (let day = 1; day <= monthLastDay; day++) {
|
||||||
const dateObj = new Date(checkYear, checkMonth, day);
|
const dateObj = new Date(checkYear, checkMonth, day);
|
||||||
|
dateObj.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// For current calendar month, only count up to today
|
||||||
|
if (isCurrentCalendarMonth && dateObj > today) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const year = dateObj.getFullYear();
|
const year = dateObj.getFullYear();
|
||||||
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||||
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
||||||
const dateISO = `${year}-${month}-${dayStr}`;
|
const dateISO = `${year}-${month}-${dayStr}`;
|
||||||
|
|
||||||
if (!isWeekendOrHoliday(dateObj)) {
|
if (!isWeekendOrHoliday(dateObj)) {
|
||||||
// Exclude vacation days from workdays count
|
// Exclude vacation and sick days from workdays count
|
||||||
if (!vacationDays.has(dateISO)) {
|
if (!vacationDays.has(dateISO) && !sickDays.has(dateISO)) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
}
|
}
|
||||||
} else if (flextimeDays.has(dateISO)) {
|
} else if (flextimeDays.has(dateISO)) {
|
||||||
@@ -1533,6 +1621,8 @@ async function calculatePreviousBalance() {
|
|||||||
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
|
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
|
||||||
const monthBalance = actualHours - targetHours;
|
const monthBalance = actualHours - targetHours;
|
||||||
|
|
||||||
|
console.log(`Month ${checkYear}-${String(checkMonth+1).padStart(2, '0')}: ${entries.length} entries, ${workdaysPassed} workdays, ${actualHours.toFixed(1)}h actual, ${targetHours.toFixed(1)}h target, balance: ${monthBalance.toFixed(1)}h`);
|
||||||
|
|
||||||
totalBalance += monthBalance;
|
totalBalance += monthBalance;
|
||||||
|
|
||||||
// Move to next month
|
// Move to next month
|
||||||
@@ -1543,9 +1633,62 @@ async function calculatePreviousBalance() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Total previous balance: ${totalBalance.toFixed(1)}h`);
|
||||||
return totalBalance;
|
return totalBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total balance from first entry up to and including the displayed month.
|
||||||
|
* For the current or future months the calculation is capped at today.
|
||||||
|
*/
|
||||||
|
async function calculateTotalBalance() {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// End of the displayed month
|
||||||
|
const displayedMonthEnd = new Date(displayYear, displayMonth + 1, 0);
|
||||||
|
displayedMonthEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Effective cut-off: end of displayed month, but never beyond today
|
||||||
|
const effectiveCutoff = displayedMonthEnd < today ? displayedMonthEnd : today;
|
||||||
|
const effectiveYear = effectiveCutoff.getFullYear();
|
||||||
|
const effectiveMonth = effectiveCutoff.getMonth();
|
||||||
|
|
||||||
|
// All months before the effective month
|
||||||
|
const previousBalance = await calculatePreviousBalance(effectiveYear, effectiveMonth);
|
||||||
|
|
||||||
|
// Add the effective month up to the effective cut-off date
|
||||||
|
const firstDay = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-01`;
|
||||||
|
const lastDayNum = new Date(effectiveYear, effectiveMonth + 1, 0).getDate();
|
||||||
|
const lastDayStr = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}`;
|
||||||
|
const entries = await fetchEntries(firstDay, lastDayStr);
|
||||||
|
|
||||||
|
const vacationDays = new Set(entries.filter(e => e.entryType === 'vacation').map(e => e.date));
|
||||||
|
const sickDays = new Set(entries.filter(e => e.entryType === 'sickday').map(e => e.date));
|
||||||
|
const flextimeDays = new Set(entries.filter(e => e.entryType === 'flextime').map(e => e.date));
|
||||||
|
|
||||||
|
let workdaysPassed = 0;
|
||||||
|
for (let day = 1; day <= lastDayNum; day++) {
|
||||||
|
const dateObj = new Date(effectiveYear, effectiveMonth, day);
|
||||||
|
dateObj.setHours(0, 0, 0, 0);
|
||||||
|
if (dateObj > effectiveCutoff) break;
|
||||||
|
const dateISO = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
if (!isWeekendOrHoliday(dateObj)) {
|
||||||
|
if (!vacationDays.has(dateISO) && !sickDays.has(dateISO)) workdaysPassed++;
|
||||||
|
} else if (flextimeDays.has(dateISO)) {
|
||||||
|
workdaysPassed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetHours = workdaysPassed * 8;
|
||||||
|
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
|
||||||
|
const monthBalance = actualHours - targetHours;
|
||||||
|
|
||||||
|
const total = previousBalance + monthBalance;
|
||||||
|
console.log(`calculateTotalBalance: displayed=${displayYear}-${displayMonth + 1}, cutoff=${effectiveYear}-${effectiveMonth + 1}, previousBalance=${previousBalance.toFixed(1)}h, effectiveMonth=${monthBalance.toFixed(1)}h, total=${total.toFixed(1)}h`);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open modal for adding/editing entry
|
* Open modal for adding/editing entry
|
||||||
*/
|
*/
|
||||||
@@ -2064,7 +2207,7 @@ async function generatePDF(options) {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { targetHours, totalNetHours, balance } = statistics;
|
const { targetHours, totalNetHours, balance } = statistics;
|
||||||
const { vacationDays = 0, flextimeDays = 0 } = additionalInfo;
|
const { vacationDays = 0, sickDays = 0, flextimeDays = 0 } = additionalInfo;
|
||||||
|
|
||||||
// Get employee data from settings
|
// Get employee data from settings
|
||||||
const employeeName = await getSetting('employeeName') || '';
|
const employeeName = await getSetting('employeeName') || '';
|
||||||
@@ -2140,12 +2283,16 @@ async function generatePDF(options) {
|
|||||||
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
|
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
|
||||||
|
|
||||||
// Additional info if needed (small, far right)
|
// Additional info if needed (small, far right)
|
||||||
if (vacationDays > 0 || flextimeDays > 0) {
|
if (vacationDays > 0 || sickDays > 0 || flextimeDays > 0) {
|
||||||
doc.setTextColor(150, 150, 150);
|
doc.setTextColor(150, 150, 150);
|
||||||
doc.setFontSize(7);
|
doc.setFontSize(7);
|
||||||
doc.setFont(undefined, 'normal');
|
doc.setFont(undefined, 'normal');
|
||||||
let infoText = '';
|
let infoText = '';
|
||||||
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
|
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
|
||||||
|
if (sickDays > 0) {
|
||||||
|
if (infoText) infoText += ' ';
|
||||||
|
infoText += `Krank: ${sickDays}`;
|
||||||
|
}
|
||||||
if (flextimeDays > 0) {
|
if (flextimeDays > 0) {
|
||||||
if (infoText) infoText += ' ';
|
if (infoText) infoText += ' ';
|
||||||
infoText += `Gleitzeit: ${flextimeDays}`;
|
infoText += `Gleitzeit: ${flextimeDays}`;
|
||||||
@@ -2276,6 +2423,13 @@ async function bulkExportPDF() {
|
|||||||
.map(e => e.date)
|
.map(e => e.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count sick days from selected entries
|
||||||
|
const sickDaysSet = new Set(
|
||||||
|
selectedEntriesData
|
||||||
|
.filter(e => e.entryType === 'sickday')
|
||||||
|
.map(e => e.date)
|
||||||
|
);
|
||||||
|
|
||||||
// Count flextime days from selected entries
|
// Count flextime days from selected entries
|
||||||
const flextimeDaysSet = new Set(
|
const flextimeDaysSet = new Set(
|
||||||
selectedEntriesData
|
selectedEntriesData
|
||||||
@@ -2290,10 +2444,11 @@ async function bulkExportPDF() {
|
|||||||
const dateObj = new Date(year, month - 1, day);
|
const dateObj = new Date(year, month - 1, day);
|
||||||
|
|
||||||
const isVacation = vacationDaysSet.has(dateISO);
|
const isVacation = vacationDaysSet.has(dateISO);
|
||||||
|
const isSickday = sickDaysSet.has(dateISO);
|
||||||
const isFlextime = flextimeDaysSet.has(dateISO);
|
const isFlextime = flextimeDaysSet.has(dateISO);
|
||||||
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
||||||
|
|
||||||
if (!isWeekendHoliday && !isVacation) {
|
if (!isWeekendHoliday && !isVacation && !isSickday) {
|
||||||
// Normal workday
|
// Normal workday
|
||||||
if (dateObj <= today) {
|
if (dateObj <= today) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
@@ -2304,12 +2459,13 @@ async function bulkExportPDF() {
|
|||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Vacation days are excluded from workday count
|
// Vacation days and sick days are excluded from workday count
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total hours and days
|
// Calculate total hours and days
|
||||||
let totalNetHours = 0;
|
let totalNetHours = 0;
|
||||||
let vacationDays = 0;
|
let vacationDays = 0;
|
||||||
|
let sickDays = 0;
|
||||||
let flextimeDays = 0;
|
let flextimeDays = 0;
|
||||||
let workEntriesCount = 0;
|
let workEntriesCount = 0;
|
||||||
|
|
||||||
@@ -2322,6 +2478,9 @@ async function bulkExportPDF() {
|
|||||||
} else if (entry.entryType === 'vacation') {
|
} else if (entry.entryType === 'vacation') {
|
||||||
vacationDays++;
|
vacationDays++;
|
||||||
totalNetHours += entry.netHours;
|
totalNetHours += entry.netHours;
|
||||||
|
} else if (entry.entryType === 'sickday') {
|
||||||
|
sickDays++;
|
||||||
|
totalNetHours += entry.netHours;
|
||||||
} else if (entry.entryType === 'flextime') {
|
} else if (entry.entryType === 'flextime') {
|
||||||
flextimeDays++;
|
flextimeDays++;
|
||||||
// Only add flextime hours if it's on a weekend/holiday
|
// Only add flextime hours if it's on a weekend/holiday
|
||||||
@@ -2373,6 +2532,13 @@ async function bulkExportPDF() {
|
|||||||
pauseText = '-';
|
pauseText = '-';
|
||||||
netHoursText = '-';
|
netHoursText = '-';
|
||||||
deviationStr = '-';
|
deviationStr = '-';
|
||||||
|
} else if (entry.entryType === 'sickday') {
|
||||||
|
locationText = 'Krank';
|
||||||
|
startTime = '-';
|
||||||
|
endTime = '-';
|
||||||
|
pauseText = '-';
|
||||||
|
netHoursText = '-';
|
||||||
|
deviationStr = '-';
|
||||||
} else if (entry.entryType === 'flextime') {
|
} else if (entry.entryType === 'flextime') {
|
||||||
locationText = 'Gleittag';
|
locationText = 'Gleittag';
|
||||||
startTime = '-';
|
startTime = '-';
|
||||||
@@ -2428,7 +2594,7 @@ async function bulkExportPDF() {
|
|||||||
subtitle: dateRange,
|
subtitle: dateRange,
|
||||||
tableData: allDaysData,
|
tableData: allDaysData,
|
||||||
statistics: { targetHours, totalNetHours, balance },
|
statistics: { targetHours, totalNetHours, balance },
|
||||||
additionalInfo: { vacationDays, flextimeDays },
|
additionalInfo: { vacationDays, sickDays, flextimeDays },
|
||||||
fileName
|
fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3343,6 +3509,13 @@ async function handleExportPDF() {
|
|||||||
.map(e => e.date)
|
.map(e => e.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Count sick days to exclude from workdays
|
||||||
|
const sickDaysSet = new Set(
|
||||||
|
entries
|
||||||
|
.filter(e => e.entryType === 'sickday')
|
||||||
|
.map(e => e.date)
|
||||||
|
);
|
||||||
|
|
||||||
// Count flextime days (they are workdays with 0 hours worked)
|
// Count flextime days (they are workdays with 0 hours worked)
|
||||||
const flextimeDaysSet = new Set(
|
const flextimeDaysSet = new Set(
|
||||||
entries
|
entries
|
||||||
@@ -3358,11 +3531,12 @@ async function handleExportPDF() {
|
|||||||
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
|
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
|
||||||
|
|
||||||
const isVacation = vacationDaysSet.has(dateISO);
|
const isVacation = vacationDaysSet.has(dateISO);
|
||||||
|
const isSickday = sickDaysSet.has(dateISO);
|
||||||
const isFlextime = flextimeDaysSet.has(dateISO);
|
const isFlextime = flextimeDaysSet.has(dateISO);
|
||||||
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
||||||
|
|
||||||
if (!isWeekendHoliday && !isVacation) {
|
if (!isWeekendHoliday && !isVacation && !isSickday) {
|
||||||
// Normal workday (excluding vacation days)
|
// Normal workday (excluding vacation and sick days)
|
||||||
totalWorkdaysInMonth++;
|
totalWorkdaysInMonth++;
|
||||||
if (dateObj <= countUntil) {
|
if (dateObj <= countUntil) {
|
||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
@@ -3374,11 +3548,12 @@ async function handleExportPDF() {
|
|||||||
workdaysPassed++;
|
workdaysPassed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Vacation days are excluded from all counts
|
// Vacation days and sick days are excluded from all counts
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalNetHours = 0;
|
let totalNetHours = 0;
|
||||||
let vacationDays = 0;
|
let vacationDays = 0;
|
||||||
|
let sickDays = 0;
|
||||||
let flextimeDays = 0;
|
let flextimeDays = 0;
|
||||||
let workEntriesCount = 0;
|
let workEntriesCount = 0;
|
||||||
|
|
||||||
@@ -3401,6 +3576,10 @@ async function handleExportPDF() {
|
|||||||
vacationDays++;
|
vacationDays++;
|
||||||
// Vacation hours are already included in netHours (8h per day typically)
|
// Vacation hours are already included in netHours (8h per day typically)
|
||||||
totalNetHours += entry.netHours;
|
totalNetHours += entry.netHours;
|
||||||
|
} else if (entry.entryType === 'sickday') {
|
||||||
|
sickDays++;
|
||||||
|
// Sick day hours are already included in netHours (8h per day typically)
|
||||||
|
totalNetHours += entry.netHours;
|
||||||
} else if (entry.entryType === 'flextime') {
|
} else if (entry.entryType === 'flextime') {
|
||||||
flextimeDays++;
|
flextimeDays++;
|
||||||
// Only add flextime hours if it's on a weekend/holiday
|
// Only add flextime hours if it's on a weekend/holiday
|
||||||
@@ -3468,6 +3647,13 @@ async function handleExportPDF() {
|
|||||||
pauseText = '-';
|
pauseText = '-';
|
||||||
netHoursText = '-';
|
netHoursText = '-';
|
||||||
deviationStr = '-';
|
deviationStr = '-';
|
||||||
|
} else if (entry.entryType === 'sickday') {
|
||||||
|
locationText = 'Krank';
|
||||||
|
startTime = '-';
|
||||||
|
endTime = '-';
|
||||||
|
pauseText = '-';
|
||||||
|
netHoursText = '-';
|
||||||
|
deviationStr = '-';
|
||||||
} else if (entry.entryType === 'flextime') {
|
} else if (entry.entryType === 'flextime') {
|
||||||
locationText = 'Gleittag';
|
locationText = 'Gleittag';
|
||||||
startTime = '-';
|
startTime = '-';
|
||||||
@@ -3520,7 +3706,7 @@ async function handleExportPDF() {
|
|||||||
subtitle: monthName,
|
subtitle: monthName,
|
||||||
tableData: allDaysData,
|
tableData: allDaysData,
|
||||||
statistics: { targetHours, totalNetHours, balance: monthBalance },
|
statistics: { targetHours, totalNetHours, balance: monthBalance },
|
||||||
additionalInfo: { vacationDays, flextimeDays },
|
additionalInfo: { vacationDays, sickDays, flextimeDays },
|
||||||
fileName
|
fileName
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||||||
end_time TEXT,
|
end_time TEXT,
|
||||||
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
|
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
|
||||||
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))
|
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
|||||||
15
server.js
15
server.js
@@ -47,13 +47,22 @@ try {
|
|||||||
|
|
||||||
if (!hasEntryTypeColumn) {
|
if (!hasEntryTypeColumn) {
|
||||||
console.log('Adding entry_type column to entries table...');
|
console.log('Adding entry_type column to entries table...');
|
||||||
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))`);
|
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))`);
|
||||||
console.log('Entry_type column added successfully');
|
console.log('Entry_type column added successfully');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during entry_type migration:', error);
|
console.error('Error during entry_type migration:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Update CHECK constraint to include 'sickday' if needed
|
||||||
|
try {
|
||||||
|
// SQLite doesn't support modifying CHECK constraints directly
|
||||||
|
// The constraint will be updated when a new sickday entry is added
|
||||||
|
console.log('Entry_type constraint check completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during entry_type constraint migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: Make start_time and end_time nullable for vacation/flextime entries
|
// Migration: Make start_time and end_time nullable for vacation/flextime entries
|
||||||
try {
|
try {
|
||||||
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
|
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
|
||||||
@@ -125,6 +134,10 @@ function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType =
|
|||||||
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entryType === 'sickday') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
// Regular work entry calculation
|
// Regular work entry calculation
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||||
|
|||||||
Reference in New Issue
Block a user