Compare commits
3 Commits
06176350b8
...
b0dd773fba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0dd773fba | ||
|
|
b2823731f1 | ||
|
|
720b3d2d03 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
docker-volume/
|
||||||
24
Dockerfile
24
Dockerfile
@@ -16,8 +16,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
# Using npm ci for reproducible builds
|
RUN npm install --omit=dev && \
|
||||||
RUN npm ci --only=production && \
|
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -30,26 +29,17 @@ WORKDIR /app
|
|||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init for proper signal handling
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Create non-root user for security
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nodejs -u 1001
|
|
||||||
|
|
||||||
# Copy dependencies from builder stage
|
# Copy dependencies from builder stage
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY --chown=nodejs:nodejs server.js ./
|
COPY server.js ./
|
||||||
COPY --chown=nodejs:nodejs package*.json ./
|
COPY schema.sql ./
|
||||||
COPY --chown=nodejs:nodejs src ./src
|
COPY package*.json ./
|
||||||
COPY --chown=nodejs:nodejs db ./db
|
COPY public ./public
|
||||||
COPY --chown=nodejs:nodejs public ./public
|
|
||||||
|
|
||||||
# Create data directory for SQLite database with proper permissions
|
# Create data directory for SQLite database
|
||||||
RUN mkdir -p /app/db && \
|
RUN mkdir -p /app/db
|
||||||
chown -R nodejs:nodejs /app/db
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nodejs
|
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
116
README.md
116
README.md
@@ -2,16 +2,75 @@
|
|||||||
|
|
||||||
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
|
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
*Hauptansicht mit Timer und Monatsübersicht*
|
||||||
|
|
||||||
|

|
||||||
|
*Detaillierte Statistiken und Urlaubsverwaltung*
|
||||||
|
|
||||||
|

|
||||||
|
*Eintragsbearbeitung und Bulk-Operationen*
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit)
|
### Zeiterfassung
|
||||||
- ✅ Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz
|
- ✅ **Start/Stop Timer**: Live-Timer mit automatischer Zeiterfassung für den aktuellen Tag
|
||||||
- ✅ Maximum von 10 Stunden Nettoarbeitszeit
|
- Pause nach 6 Stunden (30 Min) oder 9 Stunden (45 Min) gemäß deutschem Arbeitszeitgesetz
|
||||||
- ✅ Filterung nach Zeitraum
|
- Automatische Rundung auf 15-Minuten-Intervalle
|
||||||
- ✅ CSV-Export mit deutscher Formatierung
|
- Timer läuft auch nach Seiten-Reload weiter
|
||||||
- ✅ Responsive Benutzeroberfläche mit Tailwind CSS
|
- ✅ **Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause)
|
||||||
- ✅ Moderner Datums-/Zeitauswahl (Flatpickr)
|
- ✅ **Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle
|
||||||
- ✅ Docker-Containerisierung
|
- ✅ **Standort-Tracking**: Home-Office oder Büro pro Eintrag
|
||||||
|
- ✅ **Urlaub eintragen**: Urlaubstage werden nicht vom Saldo abgezogen
|
||||||
|
- ✅ **Gleittage eintragen**: Gleittage ziehen 8 Stunden vom Saldo ab
|
||||||
|
|
||||||
|
### Intelligente Berechnungen
|
||||||
|
- ✅ **Automatische Pausenberechnung** nach deutschem Arbeitszeitgesetz:
|
||||||
|
- \> 6 Stunden: 30 Minuten Pause
|
||||||
|
- \> 9 Stunden: 45 Minuten Pause
|
||||||
|
- ✅ **Maximum von 10 Stunden Nettoarbeitszeit** pro Tag
|
||||||
|
- ✅ **Monatliche Statistiken**: Soll-Stunden, Ist-Stunden, Saldo (Monat + Gesamt)
|
||||||
|
- ✅ **Arbeitstage-Berechnung**: Automatische Erkennung von Wochenenden und Feiertagen
|
||||||
|
|
||||||
|
### Bundesland-spezifische Feiertage
|
||||||
|
- ✅ **16 Bundesländer**: Auswahl des Bundeslandes für korrekte Feiertagsberechnung
|
||||||
|
- ✅ **Persistente Einstellung**: Bundesland-Auswahl wird gespeichert
|
||||||
|
- ✅ **Kollisionserkennung**: Warnung bei Feiertagen, die mit bestehenden Einträgen kollidieren
|
||||||
|
- ✅ **Alle regionalen Feiertage**: Heilige Drei Könige, Fronleichnam, Reformationstag, etc.
|
||||||
|
|
||||||
|
### Monatsansicht & Navigation
|
||||||
|
- ✅ **Monatskalender**: Vollständige Ansicht aller Tage des Monats
|
||||||
|
- ✅ **Farbcodierung**:
|
||||||
|
- Grün: Home-Office Tage
|
||||||
|
- Gelb: Urlaub
|
||||||
|
- Cyan: Gleittage
|
||||||
|
- Rot: Fehlende Einträge an Arbeitstagen
|
||||||
|
- Grau: Wochenenden
|
||||||
|
- Blau: Feiertage mit Namen
|
||||||
|
- ✅ **Vor/Zurück Navigation**: Einfaches Wechseln zwischen Monaten
|
||||||
|
- ✅ **Auto-Fill Funktion**: Automatisches Ausfüllen des gesamten Monats mit Standard-Arbeitszeiten
|
||||||
|
|
||||||
|
### Bulk-Operationen
|
||||||
|
- ✅ **Mehrfachauswahl**: Checkbox-Modus für schnelle Massenbearbeitung
|
||||||
|
- ✅ **Bulk-Standort setzen**: Mehrere Einträge auf einmal auf Home/Büro setzen
|
||||||
|
- ✅ **Bulk-Löschen**: Mehrere Einträge auf einmal löschen
|
||||||
|
|
||||||
|
### Filter & Export
|
||||||
|
- ✅ **Zeitraum-Filter**: Filterung nach Datum (Von/Bis)
|
||||||
|
- ✅ **CSV-Export (Alle)**: Export aller Einträge im gewählten Zeitraum
|
||||||
|
- ✅ **CSV-Export (Abweichungen)**: Export nur der Tage mit Abweichungen von 8,0 Stunden
|
||||||
|
- Ideal für Arbeitszeitnachweise bei Gleitzeit
|
||||||
|
- Zeigt nur relevante Über-/Unterschreitungen
|
||||||
|
- ✅ **Deutsches Format**: Komma als Dezimaltrennzeichen, DD.MM.YYYY Datumsformat
|
||||||
|
|
||||||
|
### Benutzerfreundlichkeit
|
||||||
|
- ✅ **Responsive Design**: Optimiert für Desktop, Tablet und Smartphone
|
||||||
|
- ✅ **Dark Mode**: Modernes dunkles Design für augenschonende Arbeit
|
||||||
|
- ✅ **Toast-Benachrichtigungen**: Visuelles Feedback bei Aktionen
|
||||||
|
- ✅ **Flatpickr**: Moderne Datums- und Zeitauswahl mit Touch-Support
|
||||||
|
- ✅ **Persistente Daten**: SQLite-Datenbank mit automatischer Migration
|
||||||
|
|
||||||
## Technologie-Stack
|
## Technologie-Stack
|
||||||
|
|
||||||
@@ -61,11 +120,20 @@ Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitsze
|
|||||||
|
|
||||||
## API-Endpunkte
|
## API-Endpunkte
|
||||||
|
|
||||||
|
### Zeiteinträge
|
||||||
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen
|
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen
|
||||||
- `POST /api/entries` - Neuen Eintrag erstellen
|
- `POST /api/entries` - Neuen Eintrag erstellen
|
||||||
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
|
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
|
||||||
- `DELETE /api/entries/:id` - Eintrag löschen
|
- `DELETE /api/entries/:id` - Eintrag löschen
|
||||||
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren
|
|
||||||
|
### Export
|
||||||
|
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge als CSV exportieren
|
||||||
|
- `GET /api/export-deviations?from=YYYY-MM-DD&to=YYYY-MM-DD` - Nur Abweichungen als CSV exportieren
|
||||||
|
|
||||||
|
### Einstellungen
|
||||||
|
- `GET /api/settings/:key` - Einstellung abrufen
|
||||||
|
- `POST /api/settings` - Einstellung speichern (key, value)
|
||||||
|
- `GET /api/settings` - Alle Einstellungen abrufen
|
||||||
|
|
||||||
## Installation & Ausführung
|
## Installation & Ausführung
|
||||||
|
|
||||||
@@ -144,13 +212,33 @@ http://localhost:3000
|
|||||||
|
|
||||||
## CSV-Export-Format
|
## CSV-Export-Format
|
||||||
|
|
||||||
Die exportierte CSV-Datei enthält folgende Spalten:
|
Die Anwendung bietet zwei Export-Optionen:
|
||||||
- **Datum**: Datum im Format TT.MM.JJJJ
|
|
||||||
- **Startzeit**: Startzeit im Format HH:MM
|
### 1. Vollständiger Export (📥 Button)
|
||||||
- **Endzeit**: Endzeit im Format HH:MM
|
Exportiert alle Einträge im gewählten Zeitraum (oder alle, wenn kein Filter gesetzt).
|
||||||
- **Pause in Minuten**: Pausenzeit in Minuten
|
|
||||||
|
### 2. Export nur Abweichungen (⚠️ Button)
|
||||||
|
Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweichen.
|
||||||
|
- **Zweck**: Ideal für Arbeitszeitnachweise bei Gleitzeit-Modellen
|
||||||
|
- **Inhalt**: Nur Über- und Unterschreitungen der 8-Stunden-Marke
|
||||||
|
- **Vorteil**: Übersichtlicher Nachweis für HR/Verwaltung ohne irrelevante Standard-Tage
|
||||||
|
|
||||||
|
### CSV-Spalten:
|
||||||
|
- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025)
|
||||||
|
- **Typ**: Arbeit / Urlaub / Gleitzeit
|
||||||
|
- **Startzeit**: HH:MM (z.B. 08:00, bei Urlaub/Gleitzeit: -)
|
||||||
|
- **Endzeit**: HH:MM (z.B. 17:00, bei Urlaub/Gleitzeit: -)
|
||||||
|
- **Pause in Minuten**: Ganzzahl (z.B. 30)
|
||||||
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
|
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
|
||||||
|
|
||||||
|
**Beispiel Abweichungs-Export:**
|
||||||
|
```csv
|
||||||
|
Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden
|
||||||
|
21.10.2025,08:00,18:30,45,9,75
|
||||||
|
22.10.2025,09:00,15:30,30,6,00
|
||||||
|
```
|
||||||
|
(Tage mit exakt 8,0h werden nicht exportiert)
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
Die Anwendung verwendet:
|
Die Anwendung verwendet:
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL UNIQUE,
|
|
||||||
start_time TEXT NOT NULL,
|
|
||||||
end_time TEXT NOT NULL,
|
|
||||||
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))
|
|
||||||
);
|
|
||||||
BIN
media/screenshots/Screenshot1.png
Normal file
BIN
media/screenshots/Screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 KiB |
BIN
media/screenshots/Screenshot2.png
Normal file
BIN
media/screenshots/Screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
media/screenshots/Screenshot3.png
Normal file
BIN
media/screenshots/Screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
1149
public/app.js
1149
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@
|
|||||||
<!-- Tailwind CSS via CDN -->
|
<!-- Tailwind CSS via CDN -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Lucide Icons -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
|
||||||
<!-- Flatpickr CSS -->
|
<!-- Flatpickr CSS -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||||
|
|
||||||
@@ -15,6 +18,115 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Premium Background with Texture */
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.05) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.05) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 40% 20%, rgba(16, 185, 129, 0.03) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass morphism effect */
|
||||||
|
.glass-card {
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px 0 rgba(0, 0, 0, 0.37),
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Premium card with elevation */
|
||||||
|
.premium-card {
|
||||||
|
background: linear-gradient(135deg, rgba(51, 65, 85, 0.9) 0%, rgba(30, 41, 59, 0.9) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px -15px rgba(0, 0, 0, 0.5),
|
||||||
|
0 8px 20px -8px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px -20px rgba(0, 0, 0, 0.6),
|
||||||
|
0 12px 30px -10px rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 50%, #34d399 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elevated button styles */
|
||||||
|
.btn-elevated {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 14px 0 rgba(0, 0, 0, 0.4),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-elevated::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-elevated:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-elevated:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px 0 rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-elevated:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 8px 0 rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table with depth */
|
||||||
|
.premium-table {
|
||||||
|
background: linear-gradient(180deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.95) 100%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 40px -10px rgba(0, 0, 0, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom styles for better tumbler/wheel experience */
|
/* Custom styles for better tumbler/wheel experience */
|
||||||
.flatpickr-time input {
|
.flatpickr-time input {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -33,11 +145,29 @@
|
|||||||
/* Editable cell styles */
|
/* Editable cell styles */
|
||||||
.editable-cell {
|
.editable-cell {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cell::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cell:hover::after {
|
||||||
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable-cell:hover {
|
.editable-cell:hover {
|
||||||
background-color: #374151;
|
background: linear-gradient(180deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable-cell.editing {
|
.editable-cell.editing {
|
||||||
@@ -52,6 +182,7 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
background-color: #1f2937;
|
background-color: #1f2937;
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast Notification Styles */
|
/* Toast Notification Styles */
|
||||||
@@ -68,34 +199,37 @@
|
|||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow:
|
||||||
|
0 10px 40px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from {
|
from {
|
||||||
transform: translateX(400px);
|
transform: translateX(400px) scale(0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(0);
|
transform: translateX(0) scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideOut {
|
@keyframes slideOut {
|
||||||
from {
|
from {
|
||||||
transform: translateX(0);
|
transform: translateX(0) scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(400px);
|
transform: translateX(400px) scale(0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,17 +239,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-success {
|
.toast-success {
|
||||||
background-color: #10b981;
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.95), rgba(5, 150, 105, 0.95));
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-error {
|
.toast-error {
|
||||||
background-color: #ef4444;
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95), rgba(220, 38, 38, 0.95));
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-info {
|
.toast-info {
|
||||||
background-color: #3b82f6;
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(37, 99, 235, 0.95));
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,33 +257,146 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Blink animation for running timer icon */
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-running-icon {
|
||||||
|
animation: blink 2s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat card glow effect */
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input fields with premium styling */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select {
|
||||||
|
background: rgba(31, 41, 55, 0.8) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.5) !important;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 0 3px rgba(59, 130, 246, 0.1),
|
||||||
|
0 0 20px rgba(59, 130, 246, 0.2) !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, #475569, #334155);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, #64748b, #475569);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 min-h-screen">
|
<body class="min-h-screen relative">
|
||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-8 max-w-6xl">
|
<div class="container mx-auto px-4 py-8 max-w-6xl relative z-10">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="bg-gray-800 rounded-lg shadow-md p-6 mb-6 border border-gray-700">
|
<div class="premium-card rounded-xl p-6 mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-100 mb-4">⏱️ Zeiterfassung</h1>
|
<h1 class="text-4xl font-bold mb-4 flex items-center gap-3 text-white">
|
||||||
|
<i data-lucide="clock" class="w-10 h-10 text-white"></i>
|
||||||
|
Zeiterfassung
|
||||||
|
</h1>
|
||||||
|
|
||||||
<!-- Start/Stop Timer Section -->
|
<!-- Start/Stop Timer Section -->
|
||||||
<div class="mb-6 p-4 bg-gradient-to-r from-gray-700 to-gray-800 rounded-lg border border-gray-600">
|
<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-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-400 mb-1">Heutige Arbeitszeit</div>
|
<div class="text-sm text-gray-400 mb-2 font-medium">Heutige Arbeitszeit</div>
|
||||||
<div id="timerDisplay" class="text-4xl font-bold text-gray-100">00:00:00</div>
|
<div id="timerDisplay" class="text-5xl font-bold text-white">00:00:00</div>
|
||||||
<div id="timerStatus" class="text-sm text-gray-400 mt-1">Nicht gestartet</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
|
||||||
|
</button>
|
||||||
|
<input type="text" id="manualStartTimeInput" class="hidden">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button id="btnStartWork"
|
<button id="btnStartWork"
|
||||||
class="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-2xl shadow-md" 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>
|
||||||
|
<span>Start</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnStopWork" disabled
|
<button id="btnStopWork" disabled
|
||||||
class="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-2xl shadow-md disabled:opacity-50 disabled:cursor-not-allowed" title="Stop">
|
class="btn-elevated inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold text-lg disabled:opacity-50 disabled:cursor-not-allowed" title="Stop">
|
||||||
⏹️
|
<i data-lucide="square" class="w-6 h-6"></i>
|
||||||
|
<span>Stop</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Start Time Picker Modal -->
|
||||||
|
<div id="manualTimePickerModal" class="hidden fixed inset-0 bg-black bg-opacity-70 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div class="premium-card rounded-2xl p-8 max-w-sm w-full mx-4">
|
||||||
|
<h3 class="text-xl font-bold text-gray-100 mb-6">Startzeit eingeben</h3>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-2">Zeit (HH:MM)</label>
|
||||||
|
<input type="text" id="manualTimeInput"
|
||||||
|
class="w-full px-4 py-3 bg-gray-700 text-gray-100 border border-gray-600 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 text-lg"
|
||||||
|
placeholder="09:00" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button id="btnConfirmManualTime"
|
||||||
|
class="btn-elevated flex-1 px-4 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-xl font-semibold">
|
||||||
|
Bestätigen
|
||||||
|
</button>
|
||||||
|
<button id="btnCancelManualTime"
|
||||||
|
class="flex-1 px-4 py-2.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium">
|
||||||
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,63 +418,125 @@
|
|||||||
placeholder="DD.MM.YYYY">
|
placeholder="DD.MM.YYYY">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-2">
|
||||||
<button id="btnFilter"
|
<button id="btnFilter"
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-xl" title="Filtern">
|
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>
|
||||||
|
|
||||||
<button id="btnClearFilter"
|
<button id="btnClearFilter"
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl" title="Filter zurücksetzen">
|
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>
|
||||||
|
|
||||||
<button id="btnExport"
|
<button id="btnExport"
|
||||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-xl" title="Export (alle)">
|
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>
|
||||||
|
|
||||||
<button id="btnExportDeviations"
|
<button id="btnExportDeviations"
|
||||||
class="px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-xl" title="Export (nur Abweichungen)">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 text-sm flex items-center gap-2">
|
||||||
|
<span class="text-lg">⚙️</span>
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</summary>
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics -->
|
<!-- Statistics -->
|
||||||
<div class="mb-6 bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
|
<div class="mb-8 premium-card rounded-xl p-6">
|
||||||
<h2 class="text-xl font-bold text-gray-100 mb-4">📊 Statistiken</h2>
|
<h2 class="text-2xl font-bold text-gray-100 mb-6 flex items-center gap-2">
|
||||||
|
<i data-lucide="bar-chart-3" class="w-7 h-7 text-blue-400"></i>
|
||||||
|
Statistiken
|
||||||
|
</h2>
|
||||||
|
|
||||||
<!-- Current Month Stats -->
|
<!-- Current Month Stats -->
|
||||||
<div class="mb-4">
|
<div class="mb-6">
|
||||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">Aktueller Monat</h3>
|
<h3 class="text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">Aktueller Monat</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
||||||
<div class="text-sm text-gray-400 mb-1">Soll</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Soll</div>
|
||||||
<div id="statTargetHours" class="text-2xl font-bold text-gray-100">0h</div>
|
<div id="statTargetHours" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-cyan-400">0h</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
||||||
<div class="text-sm text-gray-400 mb-1">Ist</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Ist</div>
|
||||||
<div id="statActualHours" class="text-2xl font-bold text-gray-100">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="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
||||||
<div class="text-sm text-gray-400 mb-1">Saldo (Monat)</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Saldo (Monat)</div>
|
||||||
<div id="statBalance" class="text-2xl font-bold text-gray-100">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="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
|
||||||
<div class="text-sm text-gray-400 mb-1">Arbeitstage</div>
|
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Arbeitstage</div>
|
||||||
<div id="statWorkdays" class="text-2xl font-bold text-gray-100">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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vacation Stats -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 id="vacationYearLabel" class="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2 uppercase tracking-wider">
|
||||||
|
<i data-lucide="plane" class="w-4 h-4 text-amber-400"></i>
|
||||||
|
Urlaub 2025
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="stat-card glass-card rounded-xl p-5 border border-yellow-600/30">
|
||||||
|
<div class="text-xs text-yellow-300 mb-2 uppercase tracking-wide">Genommen</div>
|
||||||
|
<div id="statVacationTaken" class="text-3xl font-bold text-yellow-100">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card glass-card rounded-xl p-5 border border-cyan-600/30">
|
||||||
|
<div class="text-xs text-cyan-300 mb-2 uppercase tracking-wide">Geplant</div>
|
||||||
|
<div id="statVacationPlanned" class="text-3xl font-bold text-cyan-100">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card glass-card rounded-xl p-5 border border-green-600/30">
|
||||||
|
<div class="text-xs text-green-300 mb-2 uppercase tracking-wide">Verfügbar</div>
|
||||||
|
<div id="statVacationRemaining" class="text-3xl font-bold text-green-100">0 / 30</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Balance -->
|
<!-- Total Balance -->
|
||||||
<div class="bg-gradient-to-r from-gray-700 to-gray-600 rounded-lg p-4 border border-gray-600">
|
<div class="glass-card rounded-xl p-6 border-2 border-purple-500/30 shadow-lg shadow-purple-500/20">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-300 mb-1">Gesamt-Saldo (inkl. Vormonat)</div>
|
<div class="text-sm text-gray-300 mb-2 uppercase tracking-wide font-semibold">Gesamt-Saldo (inkl. Vormonat)</div>
|
||||||
<div id="statTotalBalance" class="text-3xl font-bold text-gray-100">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">
|
||||||
<div class="text-xs text-gray-400">Übertrag Vormonat</div>
|
<div class="text-xs text-gray-400">Übertrag Vormonat</div>
|
||||||
@@ -238,34 +547,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Month Navigation -->
|
<!-- Month Navigation -->
|
||||||
<div class="mb-6 bg-gray-800 rounded-lg shadow-md p-4 border border-gray-700">
|
<div class="mb-8 glass-card rounded-xl p-5 border border-gray-700/50">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3 flex-wrap">
|
||||||
<button id="btnAddEntry"
|
<button id="btnToggleBulkEdit"
|
||||||
class="px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-xl" title="Neuer Eintrag">
|
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">
|
||||||
➕
|
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||||
|
<span>Auswahl</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnAutoFill"
|
<button id="btnAutoFill"
|
||||||
class="px-4 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-xl" title="Monat ausfüllen (8h)">
|
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)">
|
||||||
🔄
|
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
||||||
</button>
|
<span>Ausfüllen</span>
|
||||||
<button id="btnToggleBulkEdit"
|
|
||||||
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl" title="Mehrfachauswahl aktivieren">
|
|
||||||
☑️
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div id="monthNavigation" class="flex items-center gap-4">
|
||||||
<button id="btnPrevMonth"
|
<button id="btnPrevMonth"
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-gray-700 to-gray-600 text-gray-100 rounded-xl">
|
||||||
◀
|
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
||||||
</button>
|
</button>
|
||||||
<h2 id="currentMonthDisplay" class="text-2xl font-bold text-gray-100 min-w-[200px] text-center">
|
<h2 id="currentMonthDisplay" class="text-2xl font-bold text-white min-w-[200px] text-center">
|
||||||
<!-- Month name will be inserted here -->
|
<!-- Month name will be inserted here -->
|
||||||
</h2>
|
</h2>
|
||||||
<button id="btnNextMonth"
|
<button id="btnNextMonth"
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-gray-700 to-gray-600 text-gray-100 rounded-xl">
|
||||||
▶
|
<i data-lucide="chevron-right" class="w-6 h-6"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -274,65 +581,81 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bulk Edit Actions Bar -->
|
<!-- Bulk Edit Actions Bar -->
|
||||||
<div id="bulkEditBar" class="hidden mb-6 bg-amber-900 rounded-lg shadow-md p-4 border border-amber-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">
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-3">
|
||||||
<span id="selectedCount" class="text-white font-semibold">0 ausgewählt</span>
|
<span id="selectedCount" class="text-gray-300 font-medium">0 ausgewählt</span>
|
||||||
<button id="btnSelectAll"
|
<button id="btnSelectAll"
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-semibold">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 text-sm font-medium">
|
||||||
Alle auswählen
|
<i data-lucide="check-check" class="w-4 h-4"></i>
|
||||||
|
<span>Alle</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnDeselectAll"
|
<button id="btnDeselectAll"
|
||||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-semibold">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 text-sm font-medium">
|
||||||
Auswahl aufheben
|
<i data-lucide="x" class="w-4 h-4"></i>
|
||||||
|
<span>Keine</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<button id="btnBulkSetOffice"
|
<button id="btnBulkSetOffice"
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-semibold" title="Alle auf Präsenz setzen">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Alle auf Präsenz setzen">
|
||||||
🏢 Präsenz
|
<i data-lucide="building-2" class="w-4 h-4"></i>
|
||||||
|
<span>Präsenz</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnBulkSetHome"
|
<button id="btnBulkSetHome"
|
||||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-semibold" title="Alle auf Home Office setzen">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Alle auf Home Office setzen">
|
||||||
🏠 Home Office
|
<i data-lucide="home" class="w-4 h-4"></i>
|
||||||
|
<span>Home</span>
|
||||||
|
</button>
|
||||||
|
<button id="btnBulkSetVacation"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Urlaub eintragen">
|
||||||
|
<i data-lucide="plane" class="w-4 h-4"></i>
|
||||||
|
<span>Urlaub</span>
|
||||||
|
</button>
|
||||||
|
<button id="btnBulkSetFlextime"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-cyan-600 text-white rounded-lg hover:bg-cyan-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Gleittage eintragen">
|
||||||
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
||||||
|
<span>Gleitzeit</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnBulkDelete"
|
<button id="btnBulkDelete"
|
||||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-semibold" title="Ausgewählte löschen">
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Ausgewählte löschen">
|
||||||
🗑️ Löschen
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
<span>Löschen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entries Table -->
|
<!-- Entries Table -->
|
||||||
<div class="bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-700">
|
<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-fixed">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700 border-b border-gray-600">
|
<thead class="bg-gradient-to-r from-gray-800 to-gray-700 border-b-2 border-blue-500/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th id="checkboxHeader" class="hidden w-12 px-2 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">
|
<th id="checkboxHeader" class="hidden px-2 py-4 text-center text-xs font-bold 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-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500" title="Alle auswählen/abwählen">
|
||||||
</th>
|
</th>
|
||||||
<th class="w-12 px-2 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Tag</th>
|
<th class="px-2 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Tag</th>
|
||||||
<th class="w-28 px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Datum</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Datum</th>
|
||||||
<th class="w-20 px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Start</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Start</th>
|
||||||
<th class="w-20 px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Ende</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Ende</th>
|
||||||
<th class="w-28 px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Pause (Min)</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Pause (Min)</th>
|
||||||
<th class="w-28 px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Netto (Std)</th>
|
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Netto (Std)</th>
|
||||||
<th class="w-24 px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Ort</th>
|
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Ort</th>
|
||||||
<th class="w-24 px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Action</th>
|
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="entriesTableBody" class="bg-gray-800 divide-y divide-gray-700">
|
<tbody id="entriesTableBody" class="divide-y divide-gray-700/50">
|
||||||
<!-- Entries will be inserted here dynamically -->
|
<!-- Entries will be inserted here dynamically -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div id="emptyState" class="hidden p-12 text-center">
|
<div id="emptyState" class="hidden p-16 text-center">
|
||||||
<p class="text-gray-400 text-lg">Keine Einträge vorhanden.</p>
|
<i data-lucide="inbox" class="w-16 h-16 mx-auto mb-4 text-gray-600"></i>
|
||||||
|
<p class="text-gray-400 text-xl font-semibold">Keine Einträge vorhanden.</p>
|
||||||
<p class="text-gray-500 mt-2">Klicken Sie auf "Neuer Eintrag", um zu beginnen.</p>
|
<p class="text-gray-500 mt-2">Klicken Sie auf "Neuer Eintrag", um zu beginnen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,12 +729,14 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="button" id="btnLocationOffice"
|
<button type="button" id="btnLocationOffice"
|
||||||
class="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold flex items-center justify-center gap-2">
|
class="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm">
|
||||||
🏢 Präsenz
|
<i data-lucide="building-2" class="w-5 h-5"></i>
|
||||||
|
<span>Präsenz</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="btnLocationHome"
|
<button type="button" id="btnLocationHome"
|
||||||
class="flex-1 px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold flex items-center justify-center gap-2">
|
class="flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2">
|
||||||
🏠 Home Office
|
<i data-lucide="home" class="w-5 h-5"></i>
|
||||||
|
<span>Home Office</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="modalLocation" value="office">
|
<input type="hidden" id="modalLocation" value="office">
|
||||||
@@ -420,12 +745,14 @@
|
|||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="flex-1 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium shadow-sm">
|
||||||
Speichern
|
<i data-lucide="check" class="w-5 h-5"></i>
|
||||||
|
<span>Speichern</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="btnCancelModal"
|
<button type="button" id="btnCancelModal"
|
||||||
class="flex-1 px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors font-semibold">
|
class="flex-1 inline-flex items-center justify-center gap-2 px-6 py-2.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium">
|
||||||
Abbrechen
|
<i data-lucide="x" class="w-5 h-5"></i>
|
||||||
|
<span>Abbrechen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -438,5 +765,10 @@
|
|||||||
|
|
||||||
<!-- App Logic -->
|
<!-- App Logic -->
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize Lucide Icons -->
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
15
schema.sql
Normal file
15
schema.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL UNIQUE,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
|
||||||
|
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
219
server.js
219
server.js
@@ -12,7 +12,7 @@ app.use(express.static('public'));
|
|||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
const dbPath = path.join(__dirname, 'db', 'timetracker.db');
|
const dbPath = path.join(__dirname, 'db', 'timetracker.db');
|
||||||
const schemaPath = path.join(__dirname, 'db', 'schema.sql');
|
const schemaPath = path.join(__dirname, 'schema.sql'); // Schema one level up
|
||||||
|
|
||||||
// Ensure db directory exists
|
// Ensure db directory exists
|
||||||
const dbDir = path.dirname(dbPath);
|
const dbDir = path.dirname(dbPath);
|
||||||
@@ -40,6 +40,53 @@ try {
|
|||||||
console.error('Error during migration:', error);
|
console.error('Error during migration:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Add entry_type column if it doesn't exist
|
||||||
|
try {
|
||||||
|
const tableInfo = db.pragma('table_info(entries)');
|
||||||
|
const hasEntryTypeColumn = tableInfo.some(col => col.name === 'entry_type');
|
||||||
|
|
||||||
|
if (!hasEntryTypeColumn) {
|
||||||
|
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'))`);
|
||||||
|
console.log('Entry_type column added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during entry_type migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Make start_time and end_time nullable for vacation/flextime entries
|
||||||
|
try {
|
||||||
|
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
|
||||||
|
// If the column is already nullable, this will work; if not, we'd need to recreate the table
|
||||||
|
// For simplicity, we'll handle this in the application logic
|
||||||
|
console.log('Time columns migration check completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during time columns migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings table if it doesn't exist
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('Settings table ready');
|
||||||
|
|
||||||
|
// Initialize default settings if they don't exist
|
||||||
|
const initSetting = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
initSetting.run('bundesland', 'BW');
|
||||||
|
initSetting.run('vacationDays', '30');
|
||||||
|
console.log('Default settings initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating settings table:', error);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
console.log('Database initialized successfully');
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -65,9 +112,20 @@ function calculateAutoPause(grossHours) {
|
|||||||
* @param {string} startTime - Format: "HH:MM"
|
* @param {string} startTime - Format: "HH:MM"
|
||||||
* @param {string} endTime - Format: "HH:MM"
|
* @param {string} endTime - Format: "HH:MM"
|
||||||
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
||||||
|
* @param {string} entryType - Type of entry: 'work', 'vacation', 'flextime'
|
||||||
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
||||||
*/
|
*/
|
||||||
function calculateNetHours(startTime, endTime, pauseMinutes = null) {
|
function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType = 'work') {
|
||||||
|
// Special handling for non-work entries
|
||||||
|
if (entryType === 'vacation') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType === 'flextime') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
@@ -144,7 +202,13 @@ app.get('/api/entries', (req, res) => {
|
|||||||
|
|
||||||
// Add calculated net hours to each entry
|
// Add calculated net hours to each entry
|
||||||
const enrichedEntries = entries.map(entry => {
|
const enrichedEntries = entries.map(entry => {
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
const entryType = entry.entry_type || 'work';
|
||||||
|
const calculated = calculateNetHours(
|
||||||
|
entry.start_time,
|
||||||
|
entry.end_time,
|
||||||
|
entry.pause_minutes,
|
||||||
|
entryType
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
@@ -152,7 +216,8 @@ app.get('/api/entries', (req, res) => {
|
|||||||
endTime: entry.end_time,
|
endTime: entry.end_time,
|
||||||
pauseMinutes: entry.pause_minutes,
|
pauseMinutes: entry.pause_minutes,
|
||||||
netHours: calculated.netHours,
|
netHours: calculated.netHours,
|
||||||
location: entry.location || 'office'
|
location: entry.location || 'office',
|
||||||
|
entryType: entryType
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,30 +234,46 @@ app.get('/api/entries', (req, res) => {
|
|||||||
*/
|
*/
|
||||||
app.post('/api/entries', (req, res) => {
|
app.post('/api/entries', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
const type = entryType || 'work';
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
|
// Validate based on entry type
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ error: 'Missing required field: date' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'work' && (!startTime || !endTime)) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
// Calculate with auto-pause or use provided pause
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
let pause = 0;
|
||||||
const pause = calculated.pauseMinutes;
|
let start = startTime || '00:00';
|
||||||
|
let end = endTime || '00:00';
|
||||||
|
|
||||||
|
if (type === 'work') {
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
|
||||||
|
pause = calculated.pauseMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
const loc = location || 'office';
|
const loc = location || 'office';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)');
|
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location, entry_type) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
const result = stmt.run(date, startTime, endTime, pause, loc);
|
const result = stmt.run(date, start, end, pause, loc, type);
|
||||||
|
|
||||||
// Return the created entry with calculated fields
|
// Return the created entry with calculated fields
|
||||||
|
const calculated = calculateNetHours(start, end, pause, type);
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
id: result.lastInsertRowid,
|
id: result.lastInsertRowid,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime: start,
|
||||||
endTime,
|
endTime: end,
|
||||||
pauseMinutes: pause,
|
pauseMinutes: pause,
|
||||||
netHours: calculated.netHours,
|
netHours: calculated.netHours,
|
||||||
location: loc
|
location: loc,
|
||||||
|
entryType: type
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(201).json(newEntry);
|
res.status(201).json(newEntry);
|
||||||
@@ -216,34 +297,49 @@ app.post('/api/entries', (req, res) => {
|
|||||||
app.put('/api/entries/:id', (req, res) => {
|
app.put('/api/entries/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
const type = entryType || 'work';
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ error: 'Missing required field: date' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'work' && (!startTime || !endTime)) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
// Calculate with auto-pause or use provided pause
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
let pause = 0;
|
||||||
const pause = calculated.pauseMinutes;
|
let start = startTime || '00:00';
|
||||||
|
let end = endTime || '00:00';
|
||||||
|
|
||||||
|
if (type === 'work') {
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
|
||||||
|
pause = calculated.pauseMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
const loc = location || 'office';
|
const loc = location || 'office';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ?, entry_type = ? WHERE id = ?');
|
||||||
const result = stmt.run(date, startTime, endTime, pause, loc, id);
|
const result = stmt.run(date, start, end, pause, loc, type, id);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return res.status(404).json({ error: 'Entry not found' });
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the updated entry with calculated fields
|
// Return the updated entry with calculated fields
|
||||||
|
const calculated = calculateNetHours(start, end, pause, type);
|
||||||
const updatedEntry = {
|
const updatedEntry = {
|
||||||
id: parseInt(id),
|
id: parseInt(id),
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime: start,
|
||||||
endTime,
|
endTime: end,
|
||||||
pauseMinutes: pause,
|
pauseMinutes: pause,
|
||||||
netHours: calculated.netHours,
|
netHours: calculated.netHours,
|
||||||
location: loc
|
location: loc,
|
||||||
|
entryType: type
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(updatedEntry);
|
res.json(updatedEntry);
|
||||||
@@ -310,19 +406,23 @@ app.get('/api/export', (req, res) => {
|
|||||||
const entries = stmt.all(...params);
|
const entries = stmt.all(...params);
|
||||||
|
|
||||||
// Generate CSV with German formatting
|
// Generate CSV with German formatting
|
||||||
let csv = 'Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
let csv = 'Datum,Typ,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
||||||
|
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
const entryType = entry.entry_type || 'work';
|
||||||
|
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes, entryType);
|
||||||
|
|
||||||
// Format date as DD.MM.YYYY
|
// Format date as DD.MM.YYYY
|
||||||
const [year, month, day] = entry.date.split('-');
|
const [year, month, day] = entry.date.split('-');
|
||||||
const formattedDate = `${day}.${month}.${year}`;
|
const formattedDate = `${day}.${month}.${year}`;
|
||||||
|
|
||||||
|
// Type label
|
||||||
|
const typeLabel = entryType === 'vacation' ? 'Urlaub' : entryType === 'flextime' ? 'Gleitzeit' : 'Arbeit';
|
||||||
|
|
||||||
// Use comma as decimal separator for hours
|
// Use comma as decimal separator for hours
|
||||||
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
|
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
|
||||||
|
|
||||||
csv += `${formattedDate},${entry.start_time},${entry.end_time},${entry.pause_minutes},${netHoursFormatted}\n`;
|
csv += `${formattedDate},${typeLabel},${entry.start_time || '-'},${entry.end_time || '-'},${entry.pause_minutes},${netHoursFormatted}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
@@ -334,6 +434,71 @@ app.get('/api/export', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SETTINGS ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Get a setting by key
|
||||||
|
app.get('/api/settings/:key', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key } = req.params;
|
||||||
|
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||||
|
const result = stmt.get(key);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ key, value: result.value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting setting:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a setting
|
||||||
|
app.post('/api/settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key, value } = req.body;
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Key and value are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(key, value);
|
||||||
|
res.json({ key, value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting setting:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to set setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all settings
|
||||||
|
app.get('/api/settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT key, value FROM settings');
|
||||||
|
const settings = stmt.all();
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
settings.forEach(s => {
|
||||||
|
result[s.key] = s.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// 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