Compare commits

..

3 Commits

Author SHA1 Message Date
Felix Schlusche
b0dd773fba Add initial schema for entries and settings tables
- Created 'entries' table to track time entries with fields for date, start time, end time, pause minutes, location, and entry type.
- Created 'settings' table to store key-value pairs for application settings with an updated timestamp.
2025-10-23 14:27:25 +02:00
Felix Schlusche
b2823731f1 Add settings management with Bundesland selection and holiday calculations 2025-10-23 02:43:47 +02:00
Felix Schlusche
720b3d2d03 Refactor checkbox column for consistent layout in entries table 2025-10-23 02:18:09 +02:00
11 changed files with 1771 additions and 345 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Thumbs.db
.idea/
*.swp
*.swo
docker-volume/

View File

@@ -16,8 +16,7 @@ WORKDIR /app
COPY package*.json ./
# Install only production dependencies
# Using npm ci for reproducible builds
RUN npm ci --only=production && \
RUN npm install --omit=dev && \
npm cache clean --force
# ============================================
@@ -30,26 +29,17 @@ WORKDIR /app
# Install dumb-init for proper signal handling
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 --from=builder /app/node_modules ./node_modules
# Copy application files
COPY --chown=nodejs:nodejs server.js ./
COPY --chown=nodejs:nodejs package*.json ./
COPY --chown=nodejs:nodejs src ./src
COPY --chown=nodejs:nodejs db ./db
COPY --chown=nodejs:nodejs public ./public
COPY server.js ./
COPY schema.sql ./
COPY package*.json ./
COPY public ./public
# Create data directory for SQLite database with proper permissions
RUN mkdir -p /app/db && \
chown -R nodejs:nodejs /app/db
# Switch to non-root user
USER nodejs
# Create data directory for SQLite database
RUN mkdir -p /app/db
# Expose the application port
EXPOSE 3000

116
README.md
View File

@@ -2,16 +2,75 @@
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
## Screenshots
![Screenshot 1](media/screenshots/Screenshot1.png)
*Hauptansicht mit Timer und Monatsübersicht*
![Screenshot 2](media/screenshots/Screenshot2.png)
*Detaillierte Statistiken und Urlaubsverwaltung*
![Screenshot 3](media/screenshots/Screenshot3.png)
*Eintragsbearbeitung und Bulk-Operationen*
## Funktionen
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit)
-Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz
- ✅ Maximum von 10 Stunden Nettoarbeitszeit
- ✅ Filterung nach Zeitraum
- ✅ CSV-Export mit deutscher Formatierung
-Responsive Benutzeroberfläche mit Tailwind CSS
-Moderner Datums-/Zeitauswahl (Flatpickr)
-Docker-Containerisierung
### Zeiterfassung
-**Start/Stop Timer**: Live-Timer mit automatischer Zeiterfassung für den aktuellen Tag
- Pause nach 6 Stunden (30 Min) oder 9 Stunden (45 Min) gemäß deutschem Arbeitszeitgesetz
- Automatische Rundung auf 15-Minuten-Intervalle
- Timer läuft auch nach Seiten-Reload weiter
-**Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause)
-**Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle
-**Standort-Tracking**: Home-Office oder Büro pro Eintrag
-**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
@@ -61,11 +120,20 @@ Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitsze
## API-Endpunkte
### Zeiteinträge
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen
- `POST /api/entries` - Neuen Eintrag erstellen
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
- `DELETE /api/entries/:id` - Eintrag löschen
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren
### Export
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge als CSV exportieren
- `GET /api/export-deviations?from=YYYY-MM-DD&to=YYYY-MM-DD` - Nur Abweichungen als CSV exportieren
### Einstellungen
- `GET /api/settings/:key` - Einstellung abrufen
- `POST /api/settings` - Einstellung speichern (key, value)
- `GET /api/settings` - Alle Einstellungen abrufen
## Installation & Ausführung
@@ -144,13 +212,33 @@ http://localhost:3000
## CSV-Export-Format
Die exportierte CSV-Datei enthält folgende Spalten:
- **Datum**: Datum im Format TT.MM.JJJJ
- **Startzeit**: Startzeit im Format HH:MM
- **Endzeit**: Endzeit im Format HH:MM
- **Pause in Minuten**: Pausenzeit in Minuten
Die Anwendung bietet zwei Export-Optionen:
### 1. Vollständiger Export (📥 Button)
Exportiert alle Einträge im gewählten Zeitraum (oder alle, wenn kein Filter gesetzt).
### 2. Export nur Abweichungen (⚠️ Button)
Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweichen.
- **Zweck**: Ideal für Arbeitszeitnachweise bei Gleitzeit-Modellen
- **Inhalt**: Nur Über- und Unterschreitungen der 8-Stunden-Marke
- **Vorteil**: Übersichtlicher Nachweis für HR/Verwaltung ohne irrelevante Standard-Tage
### CSV-Spalten:
- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025)
- **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)
**Beispiel Abweichungs-Export:**
```csv
Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden
21.10.2025,08:00,18:30,45,9,75
22.10.2025,09:00,15:30,30,6,00
```
(Tage mit exakt 8,0h werden nicht exportiert)
## Entwicklung
Die Anwendung verwendet:

View File

@@ -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'))
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Flatpickr 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">
<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 */
.flatpickr-time input {
font-size: 1.2rem;
@@ -33,11 +145,29 @@
/* Editable cell styles */
.editable-cell {
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 {
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 {
@@ -52,6 +182,7 @@
font-size: 0.875rem;
background-color: #1f2937;
color: #f3f4f6;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
/* Toast Notification Styles */
@@ -68,34 +199,37 @@
.toast {
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease-out;
font-size: 14px;
font-weight: 500;
backdrop-filter: blur(10px);
}
@keyframes slideIn {
from {
transform: translateX(400px);
transform: translateX(400px) scale(0.9);
opacity: 0;
}
to {
transform: translateX(0);
transform: translateX(0) scale(1);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
transform: translateX(0) scale(1);
opacity: 1;
}
to {
transform: translateX(400px);
transform: translateX(400px) scale(0.9);
opacity: 0;
}
}
@@ -105,17 +239,17 @@
}
.toast-success {
background-color: #10b981;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.95), rgba(5, 150, 105, 0.95));
color: white;
}
.toast-error {
background-color: #ef4444;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95), rgba(220, 38, 38, 0.95));
color: white;
}
.toast-info {
background-color: #3b82f6;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(37, 99, 235, 0.95));
color: white;
}
@@ -123,33 +257,146 @@
font-size: 20px;
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>
</head>
<body class="bg-gray-900 min-h-screen">
<body class="min-h-screen relative">
<!-- Toast Container -->
<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 -->
<div class="bg-gray-800 rounded-lg shadow-md p-6 mb-6 border border-gray-700">
<h1 class="text-3xl font-bold text-gray-100 mb-4">⏱️ Zeiterfassung</h1>
<div class="premium-card rounded-xl p-6 mb-8">
<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 -->
<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>
<div class="text-sm text-gray-400 mb-1">Heutige Arbeitszeit</div>
<div id="timerDisplay" class="text-4xl font-bold text-gray-100">00:00:00</div>
<div id="timerStatus" class="text-sm text-gray-400 mt-1">Nicht gestartet</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>
<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 class="flex gap-3">
<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 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>
</div>
</div>
@@ -171,63 +418,125 @@
placeholder="DD.MM.YYYY">
</div>
<div class="flex gap-3">
<div class="flex gap-2">
<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 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 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 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>
</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>
<!-- Statistics -->
<div class="mb-6 bg-gray-800 rounded-lg shadow-md p-6 border border-gray-700">
<h2 class="text-xl font-bold text-gray-100 mb-4">📊 Statistiken</h2>
<div class="mb-8 premium-card rounded-xl p-6">
<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 -->
<div class="mb-4">
<h3 class="text-sm font-semibold text-gray-300 mb-2">Aktueller Monat</h3>
<div class="mb-6">
<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="bg-gray-700 rounded-lg p-4 border border-gray-600">
<div class="text-sm text-gray-400 mb-1">Soll</div>
<div id="statTargetHours" class="text-2xl font-bold text-gray-100">0h</div>
<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">Soll</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 class="bg-gray-700 rounded-lg p-4 border border-gray-600">
<div class="text-sm text-gray-400 mb-1">Ist</div>
<div id="statActualHours" class="text-2xl font-bold text-gray-100">0h</div>
<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">Ist</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 class="bg-gray-700 rounded-lg p-4 border border-gray-600">
<div class="text-sm text-gray-400 mb-1">Saldo (Monat)</div>
<div id="statBalance" class="text-2xl font-bold text-gray-100">0h</div>
<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 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 class="bg-gray-700 rounded-lg p-4 border border-gray-600">
<div class="text-sm text-gray-400 mb-1">Arbeitstage</div>
<div id="statWorkdays" class="text-2xl font-bold text-gray-100">0</div>
<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 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>
<!-- 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>
<div class="text-sm text-gray-300 mb-1">Gesamt-Saldo (inkl. Vormonat)</div>
<div id="statTotalBalance" class="text-3xl font-bold text-gray-100">0h</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-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 class="text-right">
<div class="text-xs text-gray-400">Übertrag Vormonat</div>
@@ -238,34 +547,32 @@
</div>
<!-- Month Navigation -->
<div class="mb-6 bg-gray-800 rounded-lg shadow-md p-4 border border-gray-700">
<div class="flex items-center justify-between">
<div class="flex gap-3">
<button id="btnAddEntry"
class="px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-xl" title="Neuer Eintrag">
<div class="mb-8 glass-card rounded-xl p-5 border border-gray-700/50">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex gap-3 flex-wrap">
<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">
<i data-lucide="check-square" class="w-5 h-5"></i>
<span>Auswahl</span>
</button>
<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)">
🔄
</button>
<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">
☑️
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>
<span>Ausfüllen</span>
</button>
</div>
<div class="flex items-center gap-4">
<div id="monthNavigation" class="flex items-center gap-4">
<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>
<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 -->
</h2>
<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>
</div>
@@ -274,65 +581,81 @@
</div>
<!-- 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 class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selectedCount" class="text-white font-semibold">0 ausgewählt</span>
<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 gap-3">
<span id="selectedCount" class="text-gray-300 font-medium">0 ausgewählt</span>
<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">
Alle auswählen
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">
<i data-lucide="check-check" class="w-4 h-4"></i>
<span>Alle</span>
</button>
<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">
Auswahl aufheben
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">
<i data-lucide="x" class="w-4 h-4"></i>
<span>Keine</span>
</button>
</div>
<div class="flex gap-3">
<div class="flex gap-2 flex-wrap">
<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">
🏢 Präsenz
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">
<i data-lucide="building-2" class="w-4 h-4"></i>
<span>Präsenz</span>
</button>
<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">
🏠 Home Office
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">
<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 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">
🗑️ 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">
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Löschen</span>
</button>
</div>
</div>
</div>
<!-- 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">
<table class="w-full table-fixed">
<thead class="bg-gray-700 border-b border-gray-600">
<table class="w-full">
<thead class="bg-gradient-to-r from-gray-800 to-gray-700 border-b-2 border-blue-500/30">
<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">
</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="w-28 px-6 py-3 text-left text-xs font-medium text-gray-400 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="w-20 px-6 py-3 text-left text-xs font-medium text-gray-400 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="w-28 px-6 py-3 text-left text-xs font-medium text-gray-400 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="w-24 px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Action</th>
<th class="px-2 py-4 text-left text-xs font-bold 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-6 py-4 text-left text-xs font-bold 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-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Pause (Min)</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-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Ort</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Action</th>
</tr>
</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 -->
</tbody>
</table>
</div>
<!-- Empty State -->
<div id="emptyState" class="hidden p-12 text-center">
<p class="text-gray-400 text-lg">Keine Einträge vorhanden.</p>
<div id="emptyState" class="hidden p-16 text-center">
<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>
</div>
</div>
@@ -406,12 +729,14 @@
</label>
<div class="flex gap-3">
<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">
🏢 Präsenz
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">
<i data-lucide="building-2" class="w-5 h-5"></i>
<span>Präsenz</span>
</button>
<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">
🏠 Home Office
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">
<i data-lucide="home" class="w-5 h-5"></i>
<span>Home Office</span>
</button>
</div>
<input type="hidden" id="modalLocation" value="office">
@@ -420,12 +745,14 @@
<!-- Buttons -->
<div class="flex gap-3 mt-6">
<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">
Speichern
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">
<i data-lucide="check" class="w-5 h-5"></i>
<span>Speichern</span>
</button>
<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">
Abbrechen
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">
<i data-lucide="x" class="w-5 h-5"></i>
<span>Abbrechen</span>
</button>
</div>
</form>
@@ -438,5 +765,10 @@
<!-- App Logic -->
<script src="app.js"></script>
<!-- Initialize Lucide Icons -->
<script>
lucide.createIcons();
</script>
</body>
</html>

15
schema.sql Normal file
View 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
View File

@@ -12,7 +12,7 @@ app.use(express.static('public'));
// Initialize Database
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
const dbDir = path.dirname(dbPath);
@@ -40,6 +40,53 @@ try {
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');
// ============================================
@@ -65,9 +112,20 @@ function calculateAutoPause(grossHours) {
* @param {string} startTime - Format: "HH:MM"
* @param {string} endTime - Format: "HH:MM"
* @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 }
*/
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 [endHour, endMin] = endTime.split(':').map(Number);
@@ -144,7 +202,13 @@ app.get('/api/entries', (req, res) => {
// Add calculated net hours to each 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 {
id: entry.id,
date: entry.date,
@@ -152,7 +216,8 @@ app.get('/api/entries', (req, res) => {
endTime: entry.end_time,
pauseMinutes: entry.pause_minutes,
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) => {
try {
const { date, startTime, endTime, pauseMinutes, location } = req.body;
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
if (!date || !startTime || !endTime) {
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
const type = entryType || 'work';
// 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
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
const pause = calculated.pauseMinutes;
let pause = 0;
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';
try {
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)');
const result = stmt.run(date, startTime, endTime, pause, loc);
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location, entry_type) VALUES (?, ?, ?, ?, ?, ?)');
const result = stmt.run(date, start, end, pause, loc, type);
// Return the created entry with calculated fields
const calculated = calculateNetHours(start, end, pause, type);
const newEntry = {
id: result.lastInsertRowid,
date,
startTime,
endTime,
startTime: start,
endTime: end,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc
location: loc,
entryType: type
};
res.status(201).json(newEntry);
@@ -216,34 +297,49 @@ app.post('/api/entries', (req, res) => {
app.put('/api/entries/:id', (req, res) => {
try {
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) {
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
const type = entryType || 'work';
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
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
const pause = calculated.pauseMinutes;
let pause = 0;
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';
try {
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?');
const result = stmt.run(date, startTime, endTime, pause, loc, 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, start, end, pause, loc, type, id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Entry not found' });
}
// Return the updated entry with calculated fields
const calculated = calculateNetHours(start, end, pause, type);
const updatedEntry = {
id: parseInt(id),
date,
startTime,
endTime,
startTime: start,
endTime: end,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc
location: loc,
entryType: type
};
res.json(updatedEntry);
@@ -310,19 +406,23 @@ app.get('/api/export', (req, res) => {
const entries = stmt.all(...params);
// 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 => {
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
const [year, month, day] = entry.date.split('-');
const formattedDate = `${day}.${month}.${year}`;
// Type label
const typeLabel = entryType === 'vacation' ? 'Urlaub' : entryType === 'flextime' ? 'Gleitzeit' : 'Arbeit';
// Use comma as decimal separator for hours
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');
@@ -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
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);