Compare commits
21 Commits
3f36ec3cc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f50e0fee7e | ||
|
|
a837a8af59 | ||
|
|
9408a13251 | ||
|
|
676dd2f497 | ||
|
|
432c3a0ccf | ||
|
|
9c25b47da1 | ||
|
|
995d1080f3 | ||
|
|
fe69bcb357 | ||
|
|
0b408c93ee | ||
|
|
0045a8f8d0 | ||
|
|
d04ab18ba1 | ||
|
|
bad91636b5 | ||
|
|
425c817522 | ||
|
|
763d7d141b | ||
|
|
17906c76f2 | ||
|
|
282aaac8ae | ||
|
|
4bdd9310ea | ||
|
|
fb33ea8144 | ||
|
|
8d24744c91 | ||
|
|
e91a2fbe3e | ||
|
|
11c9440806 |
@@ -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
|
||||||
|
|
||||||
@@ -72,19 +65,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=sha,format=short
|
type=sha,format=short,prefix=
|
||||||
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 }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
build-args: |
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
COMMIT_HASH=${{ github.sha }}
|
||||||
|
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
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 }}"
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -4,7 +4,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build - Install dependencies
|
# Stage 1: Build - Install dependencies
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Add metadata
|
# Add metadata
|
||||||
LABEL maintainer="timetracker"
|
LABEL maintainer="timetracker"
|
||||||
@@ -12,6 +12,9 @@ LABEL description="Time tracking application with persistent timer and German br
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Copy package files for dependency installation
|
# Copy package files for dependency installation
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ RUN npm install --omit=dev && \
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 2: Runtime - Slim production image
|
# Stage 2: Runtime - Slim production image
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -52,6 +55,12 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Build arguments for version info
|
||||||
|
ARG COMMIT_HASH=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
ENV COMMIT_HASH=${COMMIT_HASH}
|
||||||
|
ENV BUILD_DATE=${BUILD_DATE}
|
||||||
|
|
||||||
# Use dumb-init to handle signals properly
|
# Use dumb-init to handle signals properly
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^12.4.1",
|
||||||
"express": "^4.18.2"
|
"express": "^4.18.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,14 +53,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "9.6.0",
|
"version": "12.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
|
||||||
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x || 22.x || 23.x || 24.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
"node_modules/bindings": {
|
||||||
@@ -733,9 +736,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-abi": {
|
||||||
"version": "3.78.0",
|
"version": "3.80.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz",
|
||||||
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
|
"integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.3.5"
|
"semver": "^7.3.5"
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -7,11 +7,15 @@
|
|||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "node server.js"
|
"dev": "node server.js"
|
||||||
},
|
},
|
||||||
"keywords": ["timetracker", "express", "sqlite"],
|
"keywords": [
|
||||||
|
"timetracker",
|
||||||
|
"express",
|
||||||
|
"sqlite"
|
||||||
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"better-sqlite3": "^12.4.1",
|
||||||
"better-sqlite3": "^9.2.2"
|
"express": "^4.18.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,10 @@
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.details-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
details summary {
|
details summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
@@ -368,16 +372,56 @@
|
|||||||
|
|
||||||
<!-- Start/Stop Timer Section -->
|
<!-- Start/Stop Timer Section -->
|
||||||
<div class="mb-6 p-6 glass-card rounded-xl">
|
<div class="mb-6 p-6 glass-card rounded-xl">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
|
||||||
<div>
|
<!-- Left side: Timer display and controls -->
|
||||||
|
<div class="flex-1">
|
||||||
<div class="text-sm text-gray-400 mb-2 font-medium">Heutige Arbeitszeit</div>
|
<div class="text-sm text-gray-400 mb-2 font-medium">Heutige Arbeitszeit</div>
|
||||||
<div id="timerDisplay" class="text-5xl font-bold text-white">00:00:00</div>
|
<div id="timerDisplay" class="text-5xl font-bold text-white">00:00:00</div>
|
||||||
<button id="timerStatus" class="text-sm text-blue-400 hover:text-blue-300 mt-2 underline cursor-pointer transition-all" title="Startzeit manuell eingeben">
|
|
||||||
Nicht gestartet
|
<!-- Manual time entry link (shown when timer is not running) -->
|
||||||
|
<button id="manualTimeLink" class="text-sm text-blue-400 hover:text-blue-300 mt-2 underline cursor-pointer transition-all" title="Startzeit manuell eingeben">
|
||||||
|
Startzeit manuell eingeben
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Target hours selector -->
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<label for="targetHoursSelect" class="text-sm text-gray-400">Geplante Arbeitszeit:</label>
|
||||||
|
<select id="targetHoursSelect" class="px-3 py-1 bg-gray-700 text-gray-100 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
|
||||||
|
<option value="4">4h</option>
|
||||||
|
<option value="5">5h</option>
|
||||||
|
<option value="6">6h</option>
|
||||||
|
<option value="7">7h</option>
|
||||||
|
<option value="8" selected>8h</option>
|
||||||
|
<option value="9">9h</option>
|
||||||
|
<option value="10">10h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input type="text" id="manualStartTimeInput" class="hidden">
|
<input type="text" id="manualStartTimeInput" class="hidden">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
|
||||||
|
<!-- Right side: Timer Metrics (responsive - below on small screens, right on large) -->
|
||||||
|
<div id="timerMetrics" class="hidden w-full lg:w-auto lg:min-w-[280px] space-y-1 text-sm">
|
||||||
|
<button id="timerStatus" class="flex items-center gap-2 text-gray-300 hover:text-gray-100 cursor-pointer transition-all text-left w-full" title="Startzeit manuell eingeben">
|
||||||
|
<i data-lucide="clock" class="w-4 h-4 text-yellow-400"></i>
|
||||||
|
<span id="timerStatusText">Nicht gestartet</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2 text-gray-300">
|
||||||
|
<i data-lucide="target" class="w-4 h-4 text-green-400"></i>
|
||||||
|
<span>Soll erreicht: <span id="targetReachedTime" class="font-semibold text-white">--:--</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-gray-300">
|
||||||
|
<i data-lucide="timer" class="w-4 h-4 text-blue-400"></i>
|
||||||
|
<span>Zeit bis Soll: <span id="timeUntilTarget" class="font-semibold text-white">--:--</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-gray-300">
|
||||||
|
<i data-lucide="trending-up" class="w-4 h-4 text-purple-400"></i>
|
||||||
|
<span>Saldo bei Soll: <span id="balanceAtTarget" class="font-semibold text-white">--:--</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="flex gap-3 w-full lg:w-auto justify-center lg:justify-start">
|
||||||
<button id="btnStartWork"
|
<button id="btnStartWork"
|
||||||
class="btn-elevated inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-green-600 to-green-500 text-white rounded-xl font-semibold text-lg" title="Start">
|
class="btn-elevated inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-green-600 to-green-500 text-white rounded-xl font-semibold text-lg" title="Start">
|
||||||
<i data-lucide="play" class="w-6 h-6"></i>
|
<i data-lucide="play" class="w-6 h-6"></i>
|
||||||
@@ -414,125 +458,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CSV Filter & Export Section (Collapsible) -->
|
|
||||||
<details class="mt-4 pt-4 border-t border-gray-700">
|
|
||||||
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
|
||||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
|
||||||
<i data-lucide="filter" class="w-5 h-5 text-blue-400"></i>
|
|
||||||
<span>CSV Filter & Export</span>
|
|
||||||
</summary>
|
|
||||||
<div class="mt-4 flex flex-wrap gap-4 items-end">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
|
|
||||||
<input type="text" id="filterFrom"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="DD.MM.YYYY">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
|
|
||||||
<input type="text" id="filterTo"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="DD.MM.YYYY">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button id="btnFilter"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
|
|
||||||
<i data-lucide="search" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnClearFilter"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
|
|
||||||
<i data-lucide="x-circle" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnExport"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
|
|
||||||
<i data-lucide="download" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnExportDeviations"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
|
|
||||||
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Settings Section (Collapsible) -->
|
|
||||||
<details class="mt-4 pt-4 border-t border-gray-700">
|
|
||||||
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
|
||||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
|
||||||
<i data-lucide="settings" class="w-5 h-5 text-purple-400"></i>
|
|
||||||
<span>Einstellungen</span>
|
|
||||||
</summary>
|
|
||||||
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="employeeName" class="block text-sm font-medium text-gray-300 mb-1">Mitarbeitername</label>
|
|
||||||
<input type="text" id="employeeName" placeholder="Max Mustermann"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="employeeId" class="block text-sm font-medium text-gray-300 mb-1">Personalnummer</label>
|
|
||||||
<input type="text" id="employeeId" placeholder="12345"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="bundeslandSelect" class="block text-sm font-medium text-gray-300 mb-1">Bundesland (Feiertage)</label>
|
|
||||||
<select id="bundeslandSelect"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
||||||
<option value="BW">Baden-Württemberg</option>
|
|
||||||
<option value="BY">Bayern</option>
|
|
||||||
<option value="BE">Berlin</option>
|
|
||||||
<option value="BB">Brandenburg</option>
|
|
||||||
<option value="HB">Bremen</option>
|
|
||||||
<option value="HH">Hamburg</option>
|
|
||||||
<option value="HE">Hessen</option>
|
|
||||||
<option value="MV">Mecklenburg-Vorpommern</option>
|
|
||||||
<option value="NI">Niedersachsen</option>
|
|
||||||
<option value="NW">Nordrhein-Westfalen</option>
|
|
||||||
<option value="RP">Rheinland-Pfalz</option>
|
|
||||||
<option value="SL">Saarland</option>
|
|
||||||
<option value="SN">Sachsen</option>
|
|
||||||
<option value="ST">Sachsen-Anhalt</option>
|
|
||||||
<option value="SH">Schleswig-Holstein</option>
|
|
||||||
<option value="TH">Thüringen</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="vacationDaysInput" class="block text-sm font-medium text-gray-300 mb-1">Urlaubstage pro Jahr</label>
|
|
||||||
<input type="number" id="vacationDaysInput" min="0" max="50" value="30"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Database Export/Import -->
|
|
||||||
<div class="mt-6 pt-4 border-t border-gray-600">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
|
||||||
<i data-lucide="database" class="w-4 h-4 text-purple-400"></i>
|
|
||||||
Datenbank Verwaltung
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<button id="btnExportDB" class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
|
||||||
<i data-lucide="download" class="w-4 h-4"></i>
|
|
||||||
Datenbank exportieren
|
|
||||||
</button>
|
|
||||||
<button id="btnImportDB" class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
|
||||||
<i data-lucide="upload" class="w-4 h-4"></i>
|
|
||||||
Datenbank importieren
|
|
||||||
</button>
|
|
||||||
<input type="file" id="importDBFile" accept=".json" class="hidden">
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
<i data-lucide="info" class="w-3 h-3 inline"></i>
|
|
||||||
Export erstellt eine JSON-Datei mit allen Einträgen und Einstellungen. Import überschreibt alle vorhandenen Daten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics -->
|
<!-- Statistics -->
|
||||||
@@ -555,12 +480,20 @@
|
|||||||
<div id="statActualHours" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-400">0h</div>
|
<div id="statActualHours" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-400">0h</div>
|
||||||
</div>
|
</div>
|
||||||
<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">Saldo (Monat)</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
Saldo (Monat)
|
||||||
|
<span id="balanceFlextimeHint" class="hidden text-cyan-400 cursor-help" title="">
|
||||||
|
<i data-lucide="info" class="w-3 h-3"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div id="statBalance" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">0h</div>
|
<div id="statBalance" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">0h</div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
@@ -588,10 +521,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Balance -->
|
<!-- Total Balance -->
|
||||||
<div class="glass-card rounded-xl p-6 border-2 border-purple-500/30 shadow-lg shadow-purple-500/20">
|
<div class="glass-card rounded-xl p-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-300 mb-2 uppercase tracking-wide font-semibold">Gesamt-Saldo (inkl. Vormonat)</div>
|
<div class="text-sm text-gray-300 mb-2 uppercase tracking-wide font-semibold flex items-center gap-1">
|
||||||
|
Gesamt-Saldo (inkl. Vormonat)
|
||||||
|
<span id="totalBalanceFlextimeHint" class="hidden text-cyan-400 cursor-help" title="">
|
||||||
|
<i data-lucide="info" class="w-3 h-3"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div id="statTotalBalance" class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-blue-400">0h</div>
|
<div id="statTotalBalance" class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-blue-400">0h</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
@@ -608,14 +546,12 @@
|
|||||||
<!-- Left: Action Buttons (hidden on mobile, shown on desktop at start) -->
|
<!-- Left: Action Buttons (hidden on mobile, shown on desktop at start) -->
|
||||||
<div class="hidden lg:flex gap-3 flex-wrap justify-start order-2 lg:order-1">
|
<div class="hidden lg:flex gap-3 flex-wrap justify-start order-2 lg:order-1">
|
||||||
<button id="btnToggleBulkEdit"
|
<button id="btnToggleBulkEdit"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
||||||
<i data-lucide="check-square" class="w-5 h-5"></i>
|
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||||
<span>Auswahl</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button id="btnAutoFill"
|
<button id="btnAutoFill"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
|
||||||
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
||||||
<span>Ausfüllen</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -646,16 +582,14 @@
|
|||||||
<!-- Mobile Action Buttons (shown only on mobile, below navigation) -->
|
<!-- Mobile Action Buttons (shown only on mobile, below navigation) -->
|
||||||
<div class="flex lg:hidden gap-3 flex-wrap justify-center order-3">
|
<div class="flex lg:hidden gap-3 flex-wrap justify-center order-3">
|
||||||
<button onclick="document.getElementById('btnToggleBulkEdit').click()"
|
<button onclick="document.getElementById('btnToggleBulkEdit').click()"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
||||||
<i data-lucide="check-square" class="w-5 h-5"></i>
|
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||||
<span>Auswahl</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button onclick="document.getElementById('btnAutoFill').click()"
|
<button onclick="document.getElementById('btnAutoFill').click()"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
|
||||||
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
||||||
<span>Ausfüllen</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button onclick="document.getElementById('btnExportPDF').click()"
|
<button id="btnExportPDFMobile" onclick="document.getElementById('btnExportPDF').click()"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
|
||||||
<i data-lucide="file-text" class="w-5 h-5"></i>
|
<i data-lucide="file-text" class="w-5 h-5"></i>
|
||||||
<span>PDF Export</span>
|
<span>PDF Export</span>
|
||||||
@@ -664,6 +598,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bridge Days Recommendations -->
|
||||||
|
<details id="bridgeDaysContainer" class="mb-6 glass-card rounded-xl border border-cyan-600/30 hidden">
|
||||||
|
<summary class="cursor-pointer p-4 hover:bg-gray-700/30 rounded-t-xl transition-colors select-none">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i data-lucide="lightbulb" class="w-5 h-5 text-cyan-400"></i>
|
||||||
|
<h3 class="text-sm font-semibold text-cyan-400 uppercase tracking-wide">Brückentags-Empfehlungen</h3>
|
||||||
|
<i data-lucide="chevron-down" class="w-4 h-4 text-gray-400 ml-auto details-chevron"></i>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div class="p-4 pt-2">
|
||||||
|
<div id="bridgeDaysList" class="space-y-2">
|
||||||
|
<!-- Will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Bulk Edit Actions Bar -->
|
<!-- Bulk Edit Actions Bar -->
|
||||||
<div id="bulkEditBar" class="hidden mb-6 bg-gray-800 rounded-lg shadow p-4 border border-gray-700">
|
<div id="bulkEditBar" class="hidden mb-6 bg-gray-800 rounded-lg shadow p-4 border border-gray-700">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
@@ -720,22 +670,23 @@
|
|||||||
<div class="premium-table rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl">
|
<div class="premium-table rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gradient-to-r from-gray-800 to-gray-700 border-b-2 border-blue-500/30">
|
<thead class="bg-gradient-to-r from-slate-800/80 to-slate-700/80 backdrop-blur-sm border-b border-blue-500/20">
|
||||||
<tr>
|
<tr>
|
||||||
<th id="checkboxHeader" class="hidden px-2 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">
|
<th id="checkboxHeader" class="hidden px-3 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">
|
||||||
<input type="checkbox" id="masterCheckbox" class="w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500" title="Alle auswählen/abwählen">
|
<input type="checkbox" id="masterCheckbox" class="w-4 h-4 text-blue-500 bg-gray-700/50 border-gray-600 rounded focus:ring-2 focus:ring-blue-500/50" title="Alle auswählen/abwählen">
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Tag</th>
|
<th class="px-3 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Tag</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Datum</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Datum</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Start</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Start</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Ende</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Ende</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Pause (Min)</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Pause</th>
|
||||||
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Netto (Std)</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Netto</th>
|
||||||
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Ort</th>
|
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Saldo</th>
|
||||||
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Action</th>
|
<th class="px-4 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">Ort</th>
|
||||||
|
<th class="px-4 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="entriesTableBody" class="divide-y divide-gray-700/50">
|
<tbody id="entriesTableBody" class="divide-y divide-gray-600/50">
|
||||||
<!-- Entries will be inserted here dynamically -->
|
<!-- Entries will be inserted here dynamically -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -848,6 +799,149 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV Filter & Export Section (Collapsible) -->
|
||||||
|
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
<details class="premium-card rounded-xl p-6">
|
||||||
|
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
||||||
|
<i data-lucide="filter" class="w-5 h-5 text-blue-400"></i>
|
||||||
|
<span>CSV Filter & Export</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 items-end">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
|
||||||
|
<input type="text" id="filterFrom"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="DD.MM.YYYY">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
|
||||||
|
<input type="text" id="filterTo"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="DD.MM.YYYY">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btnFilter"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
|
||||||
|
<i data-lucide="search" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnClearFilter"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
|
||||||
|
<i data-lucide="x-circle" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnExport"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
|
||||||
|
<i data-lucide="download" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnExportDeviations"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Section (Collapsible) -->
|
||||||
|
<div class="container mx-auto px-4 pb-8 max-w-7xl">
|
||||||
|
<details class="premium-card rounded-xl p-6">
|
||||||
|
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
||||||
|
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
||||||
|
<i data-lucide="settings" class="w-5 h-5 text-purple-400"></i>
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-4 mb-2 text-right">
|
||||||
|
<span id="versionInfo" class="text-xs text-gray-500 font-mono"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="employeeName" class="block text-sm font-medium text-gray-300 mb-1">Mitarbeitername</label>
|
||||||
|
<input type="text" id="employeeName" placeholder="Max Mustermann"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="employeeId" class="block text-sm font-medium text-gray-300 mb-1">Personalnummer</label>
|
||||||
|
<input type="text" id="employeeId" placeholder="12345"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="bundeslandSelect" class="block text-sm font-medium text-gray-300 mb-1">Bundesland (Feiertage)</label>
|
||||||
|
<select id="bundeslandSelect"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<option value="BW">Baden-Württemberg</option>
|
||||||
|
<option value="BY">Bayern</option>
|
||||||
|
<option value="BE">Berlin</option>
|
||||||
|
<option value="BB">Brandenburg</option>
|
||||||
|
<option value="HB">Bremen</option>
|
||||||
|
<option value="HH">Hamburg</option>
|
||||||
|
<option value="HE">Hessen</option>
|
||||||
|
<option value="MV">Mecklenburg-Vorpommern</option>
|
||||||
|
<option value="NI">Niedersachsen</option>
|
||||||
|
<option value="NW">Nordrhein-Westfalen</option>
|
||||||
|
<option value="RP">Rheinland-Pfalz</option>
|
||||||
|
<option value="SL">Saarland</option>
|
||||||
|
<option value="SN">Sachsen</option>
|
||||||
|
<option value="ST">Sachsen-Anhalt</option>
|
||||||
|
<option value="SH">Schleswig-Holstein</option>
|
||||||
|
<option value="TH">Thüringen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="vacationDaysInput" class="block text-sm font-medium text-gray-300 mb-1">Urlaubstage pro Jahr</label>
|
||||||
|
<input type="number" id="vacationDaysInput" min="0" max="50" value="30"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company Holiday Preference -->
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex-1 min-w-full">
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Betriebsfrei am</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
|
||||||
|
<input type="radio" name="companyHoliday" value="christmas" id="companyHolidayChristmas" class="mr-2" checked>
|
||||||
|
<span>Heiligabend (24.12.)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
|
||||||
|
<input type="radio" name="companyHoliday" value="newyearseve" id="companyHolidayNewYear" class="mr-2">
|
||||||
|
<span>Silvester (31.12.)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database Export/Import -->
|
||||||
|
<div class="mt-6 pt-4 border-t border-gray-600">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<i data-lucide="database" class="w-4 h-4 text-purple-400"></i>
|
||||||
|
Datenbank Verwaltung
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button id="btnExportDB" class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||||
|
<i data-lucide="download" class="w-4 h-4"></i>
|
||||||
|
Datenbank exportieren
|
||||||
|
</button>
|
||||||
|
<button id="btnImportDB" class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||||
|
<i data-lucide="upload" class="w-4 h-4"></i>
|
||||||
|
Datenbank importieren
|
||||||
|
</button>
|
||||||
|
<input type="file" id="importDBFile" accept=".json" class="hidden">
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
<i data-lucide="info" class="w-3 h-3 inline"></i>
|
||||||
|
Export erstellt eine JSON-Datei mit allen Einträgen und Einstellungen. Import überschreibt alle vorhandenen Daten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Flatpickr JS -->
|
<!-- Flatpickr JS -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
|
||||||
@@ -865,6 +959,7 @@
|
|||||||
<script src="js/state.js"></script>
|
<script src="js/state.js"></script>
|
||||||
<script src="js/utils.js"></script>
|
<script src="js/utils.js"></script>
|
||||||
<script src="js/holidays.js"></script>
|
<script src="js/holidays.js"></script>
|
||||||
|
<script src="js/bridge-days.js"></script>
|
||||||
<script src="js/api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
|
||||||
@@ -872,15 +967,10 @@
|
|||||||
<script>
|
<script>
|
||||||
// Use a more robust initialization
|
// Use a more robust initialization
|
||||||
function initLucide() {
|
function initLucide() {
|
||||||
console.log('Checking lucide...', typeof lucide, window.lucide);
|
|
||||||
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
||||||
console.log('Initializing Lucide icons...');
|
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
} else if (window.lucide && window.lucide.createIcons) {
|
} else if (window.lucide && window.lucide.createIcons) {
|
||||||
console.log('Initializing Lucide icons from window...');
|
|
||||||
window.lucide.createIcons();
|
window.lucide.createIcons();
|
||||||
} else {
|
|
||||||
console.error('Lucide still not available after all attempts');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
219
public/js/bridge-days.js
Normal file
219
public/js/bridge-days.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Bridge Days Calculator
|
||||||
|
* Calculates optimal vacation days based on public holidays
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate bridge days and optimal vacation periods for a month
|
||||||
|
* @param {number} year - The year to calculate for
|
||||||
|
* @param {number} month - The month (0-11)
|
||||||
|
* @param {string} bundesland - The German state code
|
||||||
|
* @returns {Array} Array of bridge day recommendations
|
||||||
|
*/
|
||||||
|
function calculateBridgeDays(year, month, bundesland) {
|
||||||
|
const recommendations = [];
|
||||||
|
|
||||||
|
// Get all holidays for the year
|
||||||
|
const holidays = getPublicHolidays(year, bundesland);
|
||||||
|
|
||||||
|
// Create a calendar map for the entire year
|
||||||
|
const calendar = createYearCalendar(year, holidays);
|
||||||
|
|
||||||
|
// Find all work day blocks (consecutive work days between weekends/holidays)
|
||||||
|
const workBlocks = findWorkDayBlocks(calendar);
|
||||||
|
|
||||||
|
// Evaluate each block and calculate benefit
|
||||||
|
workBlocks.forEach(block => {
|
||||||
|
const benefit = evaluateBlock(block, calendar);
|
||||||
|
if (benefit.ratio >= 2.0) { // Only show if at least 2x benefit
|
||||||
|
recommendations.push(benefit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by benefit ratio (best deals first)
|
||||||
|
recommendations.sort((a, b) => b.ratio - a.ratio);
|
||||||
|
|
||||||
|
// Filter for the specific month
|
||||||
|
const monthRecommendations = recommendations.filter(rec => {
|
||||||
|
const startDate = new Date(rec.startDate);
|
||||||
|
return startDate.getMonth() === month && startDate.getFullYear() === year;
|
||||||
|
});
|
||||||
|
|
||||||
|
return monthRecommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a calendar map for the entire year
|
||||||
|
* @param {number} year - The year
|
||||||
|
* @param {Array} holidays - Array of holiday objects
|
||||||
|
* @returns {Map} Map of date strings to day types
|
||||||
|
*/
|
||||||
|
function createYearCalendar(year, holidays) {
|
||||||
|
const calendar = new Map();
|
||||||
|
|
||||||
|
// Create holiday map for fast lookup
|
||||||
|
const holidayMap = new Map();
|
||||||
|
holidays.forEach(h => {
|
||||||
|
const dateStr = formatDateKey(h.date);
|
||||||
|
holidayMap.set(dateStr, h.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each day of the year
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const dateStr = formatDateKey(date);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
let type;
|
||||||
|
if (holidayMap.has(dateStr)) {
|
||||||
|
type = { status: 'HOLIDAY', name: holidayMap.get(dateStr) };
|
||||||
|
} else if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||||
|
type = { status: 'WEEKEND' };
|
||||||
|
} else {
|
||||||
|
type = { status: 'WORKDAY' };
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar.set(dateStr, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date as YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function formatDateKey(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all work day blocks in the calendar
|
||||||
|
* @param {Map} calendar - The calendar map
|
||||||
|
* @returns {Array} Array of work day blocks
|
||||||
|
*/
|
||||||
|
function findWorkDayBlocks(calendar) {
|
||||||
|
const blocks = [];
|
||||||
|
let currentBlock = null;
|
||||||
|
|
||||||
|
// Sort dates for sequential processing
|
||||||
|
const sortedDates = Array.from(calendar.keys()).sort();
|
||||||
|
|
||||||
|
sortedDates.forEach(dateStr => {
|
||||||
|
const dayType = calendar.get(dateStr);
|
||||||
|
|
||||||
|
if (dayType.status === 'WORKDAY') {
|
||||||
|
if (!currentBlock) {
|
||||||
|
currentBlock = {
|
||||||
|
startDate: dateStr,
|
||||||
|
endDate: dateStr,
|
||||||
|
days: [dateStr]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentBlock.endDate = dateStr;
|
||||||
|
currentBlock.days.push(dateStr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
currentBlock = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't forget the last block
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a work day block and calculate benefit
|
||||||
|
* @param {Object} block - The work day block
|
||||||
|
* @param {Map} calendar - The calendar map
|
||||||
|
* @returns {Object} Benefit information
|
||||||
|
*/
|
||||||
|
function evaluateBlock(block, calendar) {
|
||||||
|
const vacationDaysNeeded = block.days.length;
|
||||||
|
|
||||||
|
// Find the extended free period (including surrounding weekends/holidays)
|
||||||
|
let startDate = new Date(block.startDate);
|
||||||
|
let endDate = new Date(block.endDate);
|
||||||
|
|
||||||
|
// Extend backwards to include preceding weekends/holidays
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() - 1);
|
||||||
|
while (true) {
|
||||||
|
const dateStr = formatDateKey(currentDate);
|
||||||
|
const dayType = calendar.get(dateStr);
|
||||||
|
if (!dayType || dayType.status === 'WORKDAY') break;
|
||||||
|
startDate = new Date(currentDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend forwards to include following weekends/holidays
|
||||||
|
currentDate = new Date(endDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
while (true) {
|
||||||
|
const dateStr = formatDateKey(currentDate);
|
||||||
|
const dayType = calendar.get(dateStr);
|
||||||
|
if (!dayType || dayType.status === 'WORKDAY') break;
|
||||||
|
endDate = new Date(currentDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total free days
|
||||||
|
const totalFreeDays = Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
// Calculate benefit ratio
|
||||||
|
const ratio = totalFreeDays / vacationDaysNeeded;
|
||||||
|
|
||||||
|
// Find holidays in the period for description
|
||||||
|
const holidaysInPeriod = [];
|
||||||
|
currentDate = new Date(startDate);
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const dateStr = formatDateKey(currentDate);
|
||||||
|
const dayType = calendar.get(dateStr);
|
||||||
|
if (dayType && dayType.status === 'HOLIDAY') {
|
||||||
|
holidaysInPeriod.push(dayType.name);
|
||||||
|
}
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: formatDateKey(startDate),
|
||||||
|
endDate: formatDateKey(endDate),
|
||||||
|
vacationDays: block.days,
|
||||||
|
vacationDaysNeeded: vacationDaysNeeded,
|
||||||
|
totalFreeDays: totalFreeDays,
|
||||||
|
ratio: ratio,
|
||||||
|
holidays: holidaysInPeriod
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable description for a bridge day recommendation
|
||||||
|
* @param {Object} recommendation - The recommendation object
|
||||||
|
* @returns {string} Description text
|
||||||
|
*/
|
||||||
|
function getBridgeDayDescription(recommendation) {
|
||||||
|
const { vacationDaysNeeded, totalFreeDays, ratio, holidays } = recommendation;
|
||||||
|
|
||||||
|
let description = `${vacationDaysNeeded} Urlaubstag${vacationDaysNeeded > 1 ? 'e' : ''} für ${totalFreeDays} freie Tage`;
|
||||||
|
|
||||||
|
if (holidays.length > 0) {
|
||||||
|
description += ` (inkl. ${holidays.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
description += ` - ${ratio.toFixed(1)}x Ertrag`;
|
||||||
|
|
||||||
|
return description;
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ function getPublicHolidays(year, bundesland) {
|
|||||||
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
|
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
|
||||||
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
|
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
|
||||||
|
|
||||||
|
// Company-provided holiday: Christmas Eve (24.12) or New Year's Eve (31.12)
|
||||||
|
// Default to Christmas if companyHolidayPreference is not defined
|
||||||
|
const companyHolidayPref = typeof companyHolidayPreference !== 'undefined' ? companyHolidayPreference : 'christmas';
|
||||||
|
if (companyHolidayPref === 'christmas') {
|
||||||
|
holidays.push({ date: new Date(year, 11, 24), name: 'Heiligabend (Betriebsfrei)' });
|
||||||
|
} else if (companyHolidayPref === 'newyearseve') {
|
||||||
|
holidays.push({ date: new Date(year, 11, 31), name: 'Silvester (Betriebsfrei)' });
|
||||||
|
}
|
||||||
|
|
||||||
// Heilige Drei Könige (BW, BY, ST)
|
// Heilige Drei Könige (BW, BY, ST)
|
||||||
if (['BW', 'BY', 'ST'].includes(bundesland)) {
|
if (['BW', 'BY', 'ST'].includes(bundesland)) {
|
||||||
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
|
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
|
||||||
|
|||||||
1450
public/js/main.js
1450
public/js/main.js
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,15 @@ let timerPausedDuration = 0; // Total paused time in seconds
|
|||||||
let isPaused = false;
|
let isPaused = false;
|
||||||
let pauseTimeout = null;
|
let pauseTimeout = null;
|
||||||
let currentEntryId = null; // ID of today's entry being timed
|
let currentEntryId = null; // ID of today's entry being timed
|
||||||
|
let targetHours = 8; // Target work hours per day (1-10)
|
||||||
|
|
||||||
// Current month display state
|
// Current month display state
|
||||||
let displayYear = new Date().getFullYear();
|
let displayYear = new Date().getFullYear();
|
||||||
let displayMonth = new Date().getMonth(); // 0-11
|
let displayMonth = new Date().getMonth(); // 0-11
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
|
||||||
|
|
||||||
// Bulk edit state
|
// Bulk edit state
|
||||||
let bulkEditMode = false;
|
let bulkEditMode = false;
|
||||||
let selectedEntries = new Set();
|
let selectedEntries = new Set();
|
||||||
@@ -44,6 +48,8 @@ function setCurrentEntryId(id) { currentEntryId = id; }
|
|||||||
function setDisplayYear(year) { displayYear = year; }
|
function setDisplayYear(year) { displayYear = year; }
|
||||||
function setDisplayMonth(month) { displayMonth = month; }
|
function setDisplayMonth(month) { displayMonth = month; }
|
||||||
|
|
||||||
|
function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
|
||||||
|
|
||||||
function setBulkEditMode(mode) { bulkEditMode = mode; }
|
function setBulkEditMode(mode) { bulkEditMode = mode; }
|
||||||
function clearSelectedEntries() { selectedEntries.clear(); }
|
function clearSelectedEntries() { selectedEntries.clear(); }
|
||||||
function addSelectedEntry(id) { selectedEntries.add(id); }
|
function addSelectedEntry(id) { selectedEntries.add(id); }
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
26
server.js
26
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);
|
||||||
@@ -499,6 +512,17 @@ app.get('/api/settings', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get version/commit info
|
||||||
|
app.get('/api/version', (req, res) => {
|
||||||
|
const commitHash = process.env.COMMIT_HASH || 'dev';
|
||||||
|
const buildDate = process.env.BUILD_DATE || new Date().toISOString();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
commit: commitHash,
|
||||||
|
buildDate: buildDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running on http://localhost:${PORT}`);
|
console.log(`Server is running on http://localhost:${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user