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.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ Thumbs.db
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
docker-volume/
|
||||
24
Dockerfile
24
Dockerfile
@@ -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
|
||||
|
||||
20
README.md
20
README.md
@@ -2,6 +2,17 @@
|
||||
|
||||
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
|
||||
|
||||
### Zeiterfassung
|
||||
@@ -12,6 +23,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
||||
- ✅ **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:
|
||||
@@ -31,6 +44,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
|
||||
- ✅ **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
|
||||
@@ -210,8 +225,9 @@ Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweiche
|
||||
|
||||
### CSV-Spalten:
|
||||
- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025)
|
||||
- **Startzeit**: HH:MM (z.B. 08:00)
|
||||
- **Endzeit**: HH:MM (z.B. 17:00)
|
||||
- **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)
|
||||
|
||||
|
||||
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 |
949
public/app.js
949
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -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,25 +418,25 @@
|
||||
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>
|
||||
@@ -223,43 +470,73 @@
|
||||
<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>
|
||||
@@ -270,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>
|
||||
|
||||
@@ -306,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">
|
||||
<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>
|
||||
<th id="checkboxHeader" class="hidden 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="px-2 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Tag</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Datum</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Start</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Ende</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Pause (Min)</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Netto (Std)</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-400 uppercase tracking-wider">Ort</th>
|
||||
<th class="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>
|
||||
@@ -438,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">
|
||||
@@ -452,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>
|
||||
@@ -470,5 +765,10 @@
|
||||
|
||||
<!-- App Logic -->
|
||||
<script src="app.js"></script>
|
||||
|
||||
<!-- Initialize Lucide Icons -->
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))
|
||||
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
|
||||
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
140
server.js
140
server.js
@@ -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,30 @@ 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(`
|
||||
@@ -50,6 +74,15 @@ try {
|
||||
)
|
||||
`);
|
||||
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);
|
||||
}
|
||||
@@ -79,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);
|
||||
|
||||
@@ -158,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,
|
||||
@@ -166,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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -183,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);
|
||||
@@ -230,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);
|
||||
@@ -324,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');
|
||||
|
||||
Reference in New Issue
Block a user