refactor
This commit is contained in:
@@ -40,6 +40,7 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
# Copy application files
|
# Copy application files
|
||||||
COPY --chown=nodejs:nodejs server.js ./
|
COPY --chown=nodejs:nodejs server.js ./
|
||||||
COPY --chown=nodejs:nodejs package*.json ./
|
COPY --chown=nodejs:nodejs package*.json ./
|
||||||
|
COPY --chown=nodejs:nodejs src ./src
|
||||||
COPY --chown=nodejs:nodejs db ./db
|
COPY --chown=nodejs:nodejs db ./db
|
||||||
COPY --chown=nodejs:nodejs public ./public
|
COPY --chown=nodejs:nodejs public ./public
|
||||||
|
|
||||||
|
|||||||
95
REFACTORING.md
Normal file
95
REFACTORING.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Refactoring Summary
|
||||||
|
|
||||||
|
## Backend Refactoring ✅ ABGESCHLOSSEN
|
||||||
|
|
||||||
|
### Vorher:
|
||||||
|
- `server.js`: 341 Zeilen - Alles in einer Datei
|
||||||
|
|
||||||
|
### Nachher:
|
||||||
|
- `server.js`: 22 Zeilen - Nur Express-Setup und Routing
|
||||||
|
|
||||||
|
### Neue Struktur:
|
||||||
|
```
|
||||||
|
/src
|
||||||
|
/config
|
||||||
|
└── database.js # DB-Initialisierung & Migrationen (56 Zeilen)
|
||||||
|
/utils
|
||||||
|
└── timeCalculator.js # Zeitberechnungen (67 Zeilen)
|
||||||
|
/routes
|
||||||
|
├── entries.js # CRUD-Endpunkte (189 Zeilen)
|
||||||
|
└── export.js # CSV-Export (66 Zeilen)
|
||||||
|
/middleware
|
||||||
|
└── (bereit für zukünftige Erweiterungen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vorteile:
|
||||||
|
- ✅ Separation of Concerns
|
||||||
|
- ✅ Einfachere Wartung
|
||||||
|
- ✅ Bessere Testbarkeit
|
||||||
|
- ✅ Klare Verantwortlichkeiten
|
||||||
|
|
||||||
|
## Frontend Refactoring ⏸️ TEILWEISE
|
||||||
|
|
||||||
|
### Erstellt:
|
||||||
|
```
|
||||||
|
/public/js
|
||||||
|
├── state.js # Globaler State (52 Zeilen)
|
||||||
|
/utils
|
||||||
|
├── dateUtils.js # Datums-Utilities (70 Zeilen)
|
||||||
|
└── timeUtils.js # Zeit-Utilities (55 Zeilen)
|
||||||
|
/api
|
||||||
|
└── apiClient.js # API-Aufrufe (200 Zeilen)
|
||||||
|
/ui
|
||||||
|
└── notifications.js # Toast-Benachrichtigungen (37 Zeilen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Noch zu tun (optional):
|
||||||
|
Das Frontend funktioniert noch mit der alten `app.js` (1888 Zeilen). Die erstellten Module sind bereit für die Integration, aber die vollständige Aufteilung würde erfordern:
|
||||||
|
|
||||||
|
- `/features/timer.js` - Timer-Funktionalität
|
||||||
|
- `/features/entries.js` - Entries rendern & bearbeiten
|
||||||
|
- `/features/bulkEdit.js` - Bulk-Edit
|
||||||
|
- `/features/inlineEdit.js` - Inline-Editing
|
||||||
|
- `/features/monthView.js` - Monatsansicht
|
||||||
|
- `/features/export.js` - Export-Funktionalität
|
||||||
|
- `/ui/modals.js` - Modal-Handling
|
||||||
|
- `/ui/datePickerInit.js` - Flatpickr-Initialisierung
|
||||||
|
- Refactored `app.js` mit ES6-Modulen
|
||||||
|
|
||||||
|
## Docker ✅ AKTUALISIERT
|
||||||
|
|
||||||
|
- `Dockerfile` wurde angepasst, um die neue `/src` Struktur zu kopieren
|
||||||
|
- `docker-compose.yml` wurde hinzugefügt
|
||||||
|
- README wurde mit Git-Clone und Docker Compose aktualisiert
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
### Option 1: Backend verwenden (empfohlen) ✅
|
||||||
|
Das Backend ist vollständig refactored und funktioniert! Sie können sofort:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Frontend komplett refactoren
|
||||||
|
Falls gewünscht, kann das Frontend in weiteren Schritten vollständig in Module aufgeteilt werden.
|
||||||
|
|
||||||
|
### Option 3: Hybride Lösung
|
||||||
|
Backend nutzen (refactored) + Frontend schrittweise migrieren
|
||||||
|
|
||||||
|
## Test
|
||||||
|
```bash
|
||||||
|
# Backend testen
|
||||||
|
cd /home/felix/git/timetracker
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Docker testen
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empfehlung
|
||||||
|
Das Backend-Refactoring ist abgeschlossen und funktionsfähig! 🎉
|
||||||
|
|
||||||
|
Für das Frontend empfehle ich:
|
||||||
|
1. Erstmal mit der aktuellen `app.js` weiterarbeiten
|
||||||
|
2. Bei Bedarf schrittweise einzelne Features in Module auslagern
|
||||||
|
3. ES6 Modules erst einführen, wenn nötig
|
||||||
193
public/js/api/apiClient.js
Normal file
193
public/js/api/apiClient.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* API Client for backend communication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatDateISO } from '../utils/dateUtils.js';
|
||||||
|
import { showNotification } from '../ui/notifications.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch entries from the backend
|
||||||
|
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
|
||||||
|
* @param {string|null} toDate - End date (YYYY-MM-DD)
|
||||||
|
* @returns {Promise<Array>} - Array of entries
|
||||||
|
*/
|
||||||
|
export async function fetchEntries(fromDate = null, toDate = null) {
|
||||||
|
try {
|
||||||
|
let url = '/api/entries';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (fromDate) params.append('from', fromDate);
|
||||||
|
if (toDate) params.append('to', toDate);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await response.json();
|
||||||
|
return entries;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching entries:', error);
|
||||||
|
showNotification('Fehler beim Laden der Einträge', 'error');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entry
|
||||||
|
* @param {string} date - Date in DD.MM.YYYY format
|
||||||
|
* @param {string} startTime - Start time HH:MM
|
||||||
|
* @param {string} endTime - End time HH:MM
|
||||||
|
* @param {number|null} pauseMinutes - Pause in minutes
|
||||||
|
* @param {string} location - Location (office/home)
|
||||||
|
* @returns {Promise<Object|null>} - Created entry or null
|
||||||
|
*/
|
||||||
|
export async function createEntry(date, startTime, endTime, pauseMinutes, location) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
date: formatDateISO(date),
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
location: location || 'office'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include pauseMinutes if explicitly provided (not empty)
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
|
||||||
|
body.pauseMinutes = parseInt(pauseMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to create entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await response.json();
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating entry:', error);
|
||||||
|
showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing entry
|
||||||
|
* @param {number} id - Entry ID
|
||||||
|
* @param {string} date - Date in DD.MM.YYYY format
|
||||||
|
* @param {string} startTime - Start time HH:MM
|
||||||
|
* @param {string} endTime - End time HH:MM
|
||||||
|
* @param {number|null} pauseMinutes - Pause in minutes
|
||||||
|
* @param {string} location - Location (office/home)
|
||||||
|
* @returns {Promise<Object|null>} - Updated entry or null
|
||||||
|
*/
|
||||||
|
export async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
date: formatDateISO(date),
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
location: location || 'office'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include pauseMinutes if explicitly provided (not empty)
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
|
||||||
|
body.pauseMinutes = parseInt(pauseMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/entries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to update entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await response.json();
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating entry:', error);
|
||||||
|
showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry
|
||||||
|
* @param {number} id - Entry ID
|
||||||
|
* @returns {Promise<boolean>} - True if successful
|
||||||
|
*/
|
||||||
|
export async function deleteEntry(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/entries/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting entry:', error);
|
||||||
|
showNotification('Fehler beim Löschen des Eintrags', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export entries as CSV
|
||||||
|
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
|
||||||
|
* @param {string|null} toDate - End date (YYYY-MM-DD)
|
||||||
|
*/
|
||||||
|
export async function exportEntries(fromDate = null, toDate = null) {
|
||||||
|
try {
|
||||||
|
let url = '/api/export';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (fromDate) params.append('from', fromDate);
|
||||||
|
if (toDate) params.append('to', toDate);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to export entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = 'zeiterfassung.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
showNotification('Export erfolgreich', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting entries:', error);
|
||||||
|
showNotification('Fehler beim Exportieren', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
public/js/state.js
Normal file
51
public/js/state.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Global application state
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
export let currentEditingId = null;
|
||||||
|
export let datePicker = null;
|
||||||
|
export let startTimePicker = null;
|
||||||
|
export let endTimePicker = null;
|
||||||
|
export let filterFromPicker = null;
|
||||||
|
export let filterToPicker = null;
|
||||||
|
|
||||||
|
// Timer state
|
||||||
|
export let timerInterval = null;
|
||||||
|
export let timerStartTime = null;
|
||||||
|
export let timerPausedDuration = 0; // Total paused time in seconds
|
||||||
|
export let isPaused = false;
|
||||||
|
export let pauseTimeout = null;
|
||||||
|
export let currentEntryId = null; // ID of today's entry being timed
|
||||||
|
|
||||||
|
// Current month display state
|
||||||
|
export let displayYear = new Date().getFullYear();
|
||||||
|
export let displayMonth = new Date().getMonth(); // 0-11
|
||||||
|
|
||||||
|
// Bulk edit state
|
||||||
|
export let bulkEditMode = false;
|
||||||
|
export let selectedEntries = new Set();
|
||||||
|
|
||||||
|
// Setters for state mutations
|
||||||
|
export function setCurrentEditingId(id) { currentEditingId = id; }
|
||||||
|
export function setDatePicker(picker) { datePicker = picker; }
|
||||||
|
export function setStartTimePicker(picker) { startTimePicker = picker; }
|
||||||
|
export function setEndTimePicker(picker) { endTimePicker = picker; }
|
||||||
|
export function setFilterFromPicker(picker) { filterFromPicker = picker; }
|
||||||
|
export function setFilterToPicker(picker) { filterToPicker = picker; }
|
||||||
|
|
||||||
|
export function setTimerInterval(interval) { timerInterval = interval; }
|
||||||
|
export function setTimerStartTime(time) { timerStartTime = time; }
|
||||||
|
export function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
|
||||||
|
export function setIsPaused(paused) { isPaused = paused; }
|
||||||
|
export function setPauseTimeout(timeout) { pauseTimeout = timeout; }
|
||||||
|
export function setCurrentEntryId(id) { currentEntryId = id; }
|
||||||
|
|
||||||
|
export function setDisplayYear(year) { displayYear = year; }
|
||||||
|
export function setDisplayMonth(month) { displayMonth = month; }
|
||||||
|
|
||||||
|
export function setBulkEditMode(mode) { bulkEditMode = mode; }
|
||||||
|
export function clearSelectedEntries() { selectedEntries.clear(); }
|
||||||
|
export function addSelectedEntry(id) { selectedEntries.add(id); }
|
||||||
|
export function removeSelectedEntry(id) { selectedEntries.delete(id); }
|
||||||
|
export function hasSelectedEntry(id) { return selectedEntries.has(id); }
|
||||||
38
public/js/ui/notifications.js
Normal file
38
public/js/ui/notifications.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Toast notification system
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show toast notification
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
* @param {string} type - Type of notification (success, error, info)
|
||||||
|
*/
|
||||||
|
export function showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
|
||||||
|
// Icon based on type
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-icon">${icons[type] || 'ℹ'}</span>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('hiding');
|
||||||
|
setTimeout(() => {
|
||||||
|
container.removeChild(toast);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
68
public/js/utils/dateUtils.js
Normal file
68
public/js/utils/dateUtils.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Date utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date from YYYY-MM-DD to DD.MM.YYYY
|
||||||
|
* @param {string} dateStr - Date in YYYY-MM-DD format
|
||||||
|
* @returns {string} - Date in DD.MM.YYYY format
|
||||||
|
*/
|
||||||
|
export function formatDateDisplay(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date from DD.MM.YYYY to YYYY-MM-DD
|
||||||
|
* @param {string} dateStr - Date in DD.MM.YYYY format
|
||||||
|
* @returns {string} - Date in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
export function formatDateISO(dateStr) {
|
||||||
|
const [day, month, year] = dateStr.split('.');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's date in YYYY-MM-DD format
|
||||||
|
* @returns {string} - Today's date
|
||||||
|
*/
|
||||||
|
export function getTodayISO() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day of week name in German
|
||||||
|
* @param {Date} date - Date object
|
||||||
|
* @returns {string} - German day name (Mo, Di, Mi, etc.)
|
||||||
|
*/
|
||||||
|
export function getDayOfWeek(date) {
|
||||||
|
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
return days[date.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get month name in German
|
||||||
|
* @param {number} monthIndex - Month index (0-11)
|
||||||
|
* @returns {string} - German month name
|
||||||
|
*/
|
||||||
|
export function getMonthName(monthIndex) {
|
||||||
|
const months = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||||
|
];
|
||||||
|
return months[monthIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is weekend or holiday
|
||||||
|
* @param {Date} date - Date object
|
||||||
|
* @returns {boolean} - True if weekend or holiday
|
||||||
|
*/
|
||||||
|
export function isWeekendOrHoliday(date) {
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
|
||||||
|
}
|
||||||
54
public/js/utils/timeUtils.js
Normal file
54
public/js/utils/timeUtils.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Time utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round time down to nearest 15 minutes
|
||||||
|
* @param {Date} date - Date object
|
||||||
|
* @returns {Date} - Rounded date
|
||||||
|
*/
|
||||||
|
export function roundDownTo15Min(date) {
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
||||||
|
date.setMinutes(roundedMinutes);
|
||||||
|
date.setSeconds(0);
|
||||||
|
date.setMilliseconds(0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round time up to nearest 15 minutes
|
||||||
|
* @param {Date} date - Date object
|
||||||
|
* @returns {Date} - Rounded date
|
||||||
|
*/
|
||||||
|
export function roundUpTo15Min(date) {
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
const roundedMinutes = Math.ceil(minutes / 15) * 15;
|
||||||
|
date.setMinutes(roundedMinutes);
|
||||||
|
date.setSeconds(0);
|
||||||
|
date.setMilliseconds(0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time as HH:MM
|
||||||
|
* @param {Date} date - Date object
|
||||||
|
* @returns {string} - Time in HH:MM format
|
||||||
|
*/
|
||||||
|
export function formatTime(date) {
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to HH:MM:SS
|
||||||
|
* @param {number} seconds - Duration in seconds
|
||||||
|
* @returns {string} - Formatted duration
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
331
server.js
331
server.js
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const { initializeDatabase } = require('./src/config/database');
|
||||||
const fs = require('fs');
|
const createEntriesRouter = require('./src/routes/entries');
|
||||||
const Database = require('better-sqlite3');
|
const createExportRouter = require('./src/routes/export');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -11,328 +11,11 @@ app.use(express.json());
|
|||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
const dbPath = path.join(__dirname, 'db', 'timetracker.db');
|
const db = initializeDatabase();
|
||||||
const schemaPath = path.join(__dirname, 'db', 'schema.sql');
|
|
||||||
|
|
||||||
// Ensure db directory exists
|
// Mount Routes
|
||||||
const dbDir = path.dirname(dbPath);
|
app.use('/api/entries', createEntriesRouter(db));
|
||||||
if (!fs.existsSync(dbDir)) {
|
app.use('/api/export', createExportRouter(db));
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Create table if it doesn't exist
|
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
|
||||||
db.exec(schema);
|
|
||||||
|
|
||||||
// Migration: Add location column if it doesn't exist
|
|
||||||
try {
|
|
||||||
const tableInfo = db.pragma('table_info(entries)');
|
|
||||||
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
|
|
||||||
|
|
||||||
if (!hasLocationColumn) {
|
|
||||||
console.log('Adding location column to entries table...');
|
|
||||||
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
|
|
||||||
console.log('Location column added successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during migration:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// BUSINESS LOGIC: Net Hours Calculation & Caps
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-calculate pause based on German break rules
|
|
||||||
* @param {number} grossHours - Total work hours
|
|
||||||
* @returns {number} - Pause in minutes
|
|
||||||
*/
|
|
||||||
function calculateAutoPause(grossHours) {
|
|
||||||
if (grossHours > 9) {
|
|
||||||
return 45;
|
|
||||||
} else if (grossHours > 6) {
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate net hours with pause and 10-hour cap
|
|
||||||
* @param {string} startTime - Format: "HH:MM"
|
|
||||||
* @param {string} endTime - Format: "HH:MM"
|
|
||||||
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
|
||||||
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
|
||||||
*/
|
|
||||||
function calculateNetHours(startTime, endTime, pauseMinutes = null) {
|
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
|
||||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
|
||||||
|
|
||||||
const startTotalMin = startHour * 60 + startMin;
|
|
||||||
const endTotalMin = endHour * 60 + endMin;
|
|
||||||
|
|
||||||
// Handle overnight shifts
|
|
||||||
let diffMin = endTotalMin - startTotalMin;
|
|
||||||
if (diffMin < 0) {
|
|
||||||
diffMin += 24 * 60; // Add 24 hours
|
|
||||||
}
|
|
||||||
|
|
||||||
const grossHours = diffMin / 60;
|
|
||||||
|
|
||||||
// Calculate required minimum pause based on gross hours
|
|
||||||
const requiredMinPause = calculateAutoPause(grossHours);
|
|
||||||
|
|
||||||
// Determine actual pause to use
|
|
||||||
let actualPause;
|
|
||||||
if (pauseMinutes !== null && pauseMinutes !== undefined) {
|
|
||||||
// Manual pause provided - enforce minimum
|
|
||||||
actualPause = Math.max(pauseMinutes, requiredMinPause);
|
|
||||||
} else {
|
|
||||||
// No pause provided - use required minimum
|
|
||||||
actualPause = requiredMinPause;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate net hours
|
|
||||||
const netMinutes = diffMin - actualPause;
|
|
||||||
let netHours = netMinutes / 60;
|
|
||||||
|
|
||||||
// Cap at 10 hours
|
|
||||||
if (netHours > 10) {
|
|
||||||
netHours = 10.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
grossHours: parseFloat(grossHours.toFixed(2)),
|
|
||||||
pauseMinutes: actualPause,
|
|
||||||
netHours: parseFloat(netHours.toFixed(2))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// API ENDPOINTS
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
||||||
* Get all entries in a date range
|
|
||||||
*/
|
|
||||||
app.get('/api/entries', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { from, to } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM entries';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (from && to) {
|
|
||||||
query += ' WHERE date >= ? AND date <= ?';
|
|
||||||
params.push(from, to);
|
|
||||||
} else if (from) {
|
|
||||||
query += ' WHERE date >= ?';
|
|
||||||
params.push(from);
|
|
||||||
} else if (to) {
|
|
||||||
query += ' WHERE date <= ?';
|
|
||||||
params.push(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY date DESC, start_time DESC';
|
|
||||||
|
|
||||||
const stmt = db.prepare(query);
|
|
||||||
const entries = stmt.all(...params);
|
|
||||||
|
|
||||||
// Add calculated net hours to each entry
|
|
||||||
const enrichedEntries = entries.map(entry => {
|
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
|
||||||
return {
|
|
||||||
id: entry.id,
|
|
||||||
date: entry.date,
|
|
||||||
startTime: entry.start_time,
|
|
||||||
endTime: entry.end_time,
|
|
||||||
pauseMinutes: entry.pause_minutes,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: entry.location || 'office'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(enrichedEntries);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching entries:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch entries' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/entries
|
|
||||||
* Create a new entry
|
|
||||||
*/
|
|
||||||
app.post('/api/entries', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
|
||||||
const 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);
|
|
||||||
|
|
||||||
// Return the created entry with calculated fields
|
|
||||||
const newEntry = {
|
|
||||||
id: result.lastInsertRowid,
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
pauseMinutes: pause,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: loc
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(201).json(newEntry);
|
|
||||||
} catch (dbError) {
|
|
||||||
// Check for UNIQUE constraint violation
|
|
||||||
if (dbError.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
|
||||||
}
|
|
||||||
throw dbError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to create entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/entries/:id
|
|
||||||
* Update an existing entry
|
|
||||||
*/
|
|
||||||
app.put('/api/entries/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
|
||||||
const 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);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({ error: 'Entry not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the updated entry with calculated fields
|
|
||||||
const updatedEntry = {
|
|
||||||
id: parseInt(id),
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
pauseMinutes: pause,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: loc
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(updatedEntry);
|
|
||||||
} catch (dbError) {
|
|
||||||
// Check for UNIQUE constraint violation
|
|
||||||
if (dbError.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
|
||||||
}
|
|
||||||
throw dbError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/entries/:id
|
|
||||||
* Delete an entry
|
|
||||||
*/
|
|
||||||
app.delete('/api/entries/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
|
||||||
const result = stmt.run(id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({ error: 'Entry not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: 'Entry deleted successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to delete entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
||||||
* Export entries as CSV
|
|
||||||
*/
|
|
||||||
app.get('/api/export', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { from, to } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM entries';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (from && to) {
|
|
||||||
query += ' WHERE date >= ? AND date <= ?';
|
|
||||||
params.push(from, to);
|
|
||||||
} else if (from) {
|
|
||||||
query += ' WHERE date >= ?';
|
|
||||||
params.push(from);
|
|
||||||
} else if (to) {
|
|
||||||
query += ' WHERE date <= ?';
|
|
||||||
params.push(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY date ASC, start_time ASC';
|
|
||||||
|
|
||||||
const stmt = db.prepare(query);
|
|
||||||
const entries = stmt.all(...params);
|
|
||||||
|
|
||||||
// Generate CSV with German formatting
|
|
||||||
let csv = 'Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
|
||||||
|
|
||||||
// Format date as DD.MM.YYYY
|
|
||||||
const [year, month, day] = entry.date.split('-');
|
|
||||||
const formattedDate = `${day}.${month}.${year}`;
|
|
||||||
|
|
||||||
// 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`;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
|
|
||||||
res.send(csv);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting entries:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to export entries' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
52
src/config/database.js
Normal file
52
src/config/database.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize and configure the SQLite database
|
||||||
|
* @returns {Database} - Configured database instance
|
||||||
|
*/
|
||||||
|
function initializeDatabase() {
|
||||||
|
const dbPath = path.join(__dirname, '..', '..', 'db', 'timetracker.db');
|
||||||
|
const schemaPath = path.join(__dirname, '..', '..', 'db', 'schema.sql');
|
||||||
|
|
||||||
|
// Ensure db directory exists
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Create table if it doesn't exist
|
||||||
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
runMigrations(db);
|
||||||
|
|
||||||
|
console.log('Database initialized successfully');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run database migrations
|
||||||
|
* @param {Database} db - Database instance
|
||||||
|
*/
|
||||||
|
function runMigrations(db) {
|
||||||
|
// Migration: Add location column if it doesn't exist
|
||||||
|
try {
|
||||||
|
const tableInfo = db.pragma('table_info(entries)');
|
||||||
|
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
|
||||||
|
|
||||||
|
if (!hasLocationColumn) {
|
||||||
|
console.log('Adding location column to entries table...');
|
||||||
|
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
|
||||||
|
console.log('Location column added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during migration:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initializeDatabase };
|
||||||
181
src/routes/entries.js
Normal file
181
src/routes/entries.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { calculateNetHours } = require('../utils/timeCalculator');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize routes with database instance
|
||||||
|
* @param {Database} db - SQLite database instance
|
||||||
|
* @returns {Router} - Configured Express router
|
||||||
|
*/
|
||||||
|
function createEntriesRouter(db) {
|
||||||
|
/**
|
||||||
|
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
* Get all entries in a date range
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM entries';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
query += ' WHERE date >= ? AND date <= ?';
|
||||||
|
params.push(from, to);
|
||||||
|
} else if (from) {
|
||||||
|
query += ' WHERE date >= ?';
|
||||||
|
params.push(from);
|
||||||
|
} else if (to) {
|
||||||
|
query += ' WHERE date <= ?';
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY date DESC, start_time DESC';
|
||||||
|
|
||||||
|
const stmt = db.prepare(query);
|
||||||
|
const entries = stmt.all(...params);
|
||||||
|
|
||||||
|
// Add calculated net hours to each entry
|
||||||
|
const enrichedEntries = entries.map(entry => {
|
||||||
|
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
date: entry.date,
|
||||||
|
startTime: entry.start_time,
|
||||||
|
endTime: entry.end_time,
|
||||||
|
pauseMinutes: entry.pause_minutes,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: entry.location || 'office'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(enrichedEntries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching entries:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch entries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/entries
|
||||||
|
* Create a new entry
|
||||||
|
*/
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
||||||
|
|
||||||
|
if (!date || !startTime || !endTime) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate with auto-pause or use provided pause
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
// Return the created entry with calculated fields
|
||||||
|
const newEntry = {
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
pauseMinutes: pause,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: loc
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(newEntry);
|
||||||
|
} catch (dbError) {
|
||||||
|
// Check for UNIQUE constraint violation
|
||||||
|
if (dbError.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
||||||
|
}
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/entries/:id
|
||||||
|
* Update an existing entry
|
||||||
|
*/
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
||||||
|
|
||||||
|
if (!date || !startTime || !endTime) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate with auto-pause or use provided pause
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated entry with calculated fields
|
||||||
|
const updatedEntry = {
|
||||||
|
id: parseInt(id),
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
pauseMinutes: pause,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: loc
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(updatedEntry);
|
||||||
|
} catch (dbError) {
|
||||||
|
// Check for UNIQUE constraint violation
|
||||||
|
if (dbError.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
||||||
|
}
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/entries/:id
|
||||||
|
* Delete an entry
|
||||||
|
*/
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
||||||
|
const result = stmt.run(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Entry deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createEntriesRouter;
|
||||||
66
src/routes/export.js
Normal file
66
src/routes/export.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { calculateNetHours } = require('../utils/timeCalculator');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize export routes with database instance
|
||||||
|
* @param {Database} db - SQLite database instance
|
||||||
|
* @returns {Router} - Configured Express router
|
||||||
|
*/
|
||||||
|
function createExportRouter(db) {
|
||||||
|
/**
|
||||||
|
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
* Export entries as CSV
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM entries';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
query += ' WHERE date >= ? AND date <= ?';
|
||||||
|
params.push(from, to);
|
||||||
|
} else if (from) {
|
||||||
|
query += ' WHERE date >= ?';
|
||||||
|
params.push(from);
|
||||||
|
} else if (to) {
|
||||||
|
query += ' WHERE date <= ?';
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY date ASC, start_time ASC';
|
||||||
|
|
||||||
|
const stmt = db.prepare(query);
|
||||||
|
const entries = stmt.all(...params);
|
||||||
|
|
||||||
|
// Generate CSV with German formatting
|
||||||
|
let csv = 'Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
||||||
|
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
||||||
|
|
||||||
|
// Format date as DD.MM.YYYY
|
||||||
|
const [year, month, day] = entry.date.split('-');
|
||||||
|
const formattedDate = `${day}.${month}.${year}`;
|
||||||
|
|
||||||
|
// 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`;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting entries:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to export entries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createExportRouter;
|
||||||
69
src/utils/timeCalculator.js
Normal file
69
src/utils/timeCalculator.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Auto-calculate pause based on German break rules
|
||||||
|
* @param {number} grossHours - Total work hours
|
||||||
|
* @returns {number} - Pause in minutes
|
||||||
|
*/
|
||||||
|
function calculateAutoPause(grossHours) {
|
||||||
|
if (grossHours > 9) {
|
||||||
|
return 45;
|
||||||
|
} else if (grossHours > 6) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate net hours with pause and 10-hour cap
|
||||||
|
* @param {string} startTime - Format: "HH:MM"
|
||||||
|
* @param {string} endTime - Format: "HH:MM"
|
||||||
|
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
||||||
|
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
||||||
|
*/
|
||||||
|
function calculateNetHours(startTime, endTime, pauseMinutes = null) {
|
||||||
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const startTotalMin = startHour * 60 + startMin;
|
||||||
|
const endTotalMin = endHour * 60 + endMin;
|
||||||
|
|
||||||
|
// Handle overnight shifts
|
||||||
|
let diffMin = endTotalMin - startTotalMin;
|
||||||
|
if (diffMin < 0) {
|
||||||
|
diffMin += 24 * 60; // Add 24 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossHours = diffMin / 60;
|
||||||
|
|
||||||
|
// Calculate required minimum pause based on gross hours
|
||||||
|
const requiredMinPause = calculateAutoPause(grossHours);
|
||||||
|
|
||||||
|
// Determine actual pause to use
|
||||||
|
let actualPause;
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined) {
|
||||||
|
// Manual pause provided - enforce minimum
|
||||||
|
actualPause = Math.max(pauseMinutes, requiredMinPause);
|
||||||
|
} else {
|
||||||
|
// No pause provided - use required minimum
|
||||||
|
actualPause = requiredMinPause;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate net hours
|
||||||
|
const netMinutes = diffMin - actualPause;
|
||||||
|
let netHours = netMinutes / 60;
|
||||||
|
|
||||||
|
// Cap at 10 hours
|
||||||
|
if (netHours > 10) {
|
||||||
|
netHours = 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossHours: parseFloat(grossHours.toFixed(2)),
|
||||||
|
pauseMinutes: actualPause,
|
||||||
|
netHours: parseFloat(netHours.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calculateAutoPause,
|
||||||
|
calculateNetHours
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user