517 lines
15 KiB
JavaScript
517 lines
15 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
app.use(express.static('public'));
|
|
|
|
// Initialize Database
|
|
const dbPath = path.join(__dirname, 'db', 'timetracker.db');
|
|
const schemaPath = path.join(__dirname, 'schema.sql'); // Schema one level up
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Migration: Add entry_type column if it doesn't exist
|
|
try {
|
|
const tableInfo = db.pragma('table_info(entries)');
|
|
const hasEntryTypeColumn = tableInfo.some(col => col.name === 'entry_type');
|
|
|
|
if (!hasEntryTypeColumn) {
|
|
console.log('Adding entry_type column to entries table...');
|
|
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))`);
|
|
console.log('Entry_type column added successfully');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during entry_type migration:', error);
|
|
}
|
|
|
|
// Migration: Make start_time and end_time nullable for vacation/flextime entries
|
|
try {
|
|
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
|
|
// If the column is already nullable, this will work; if not, we'd need to recreate the table
|
|
// For simplicity, we'll handle this in the application logic
|
|
console.log('Time columns migration check completed');
|
|
} catch (error) {
|
|
console.error('Error during time columns migration:', error);
|
|
}
|
|
|
|
// Create settings table if it doesn't exist
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
console.log('Settings table ready');
|
|
|
|
// Initialize default settings if they don't exist
|
|
const initSetting = db.prepare(`
|
|
INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)
|
|
`);
|
|
|
|
initSetting.run('bundesland', 'BW');
|
|
initSetting.run('vacationDays', '30');
|
|
console.log('Default settings initialized');
|
|
} catch (error) {
|
|
console.error('Error creating settings table:', error);
|
|
}
|
|
|
|
console.log('Database initialized successfully');
|
|
|
|
// ============================================
|
|
// 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)
|
|
* @param {string} entryType - Type of entry: 'work', 'vacation', 'flextime'
|
|
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
|
*/
|
|
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);
|
|
|
|
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 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,
|
|
startTime: entry.start_time,
|
|
endTime: entry.end_time,
|
|
pauseMinutes: entry.pause_minutes,
|
|
netHours: calculated.netHours,
|
|
location: entry.location || 'office',
|
|
entryType: entryType
|
|
};
|
|
});
|
|
|
|
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, entryType } = req.body;
|
|
|
|
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
|
|
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, 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: start,
|
|
endTime: end,
|
|
pauseMinutes: pause,
|
|
netHours: calculated.netHours,
|
|
location: loc,
|
|
entryType: type
|
|
};
|
|
|
|
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, entryType } = req.body;
|
|
|
|
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
|
|
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 = ?, 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: start,
|
|
endTime: end,
|
|
pauseMinutes: pause,
|
|
netHours: calculated.netHours,
|
|
location: loc,
|
|
entryType: type
|
|
};
|
|
|
|
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,Typ,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
|
|
|
entries.forEach(entry => {
|
|
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},${typeLabel},${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' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// SETTINGS ENDPOINTS
|
|
// ============================================
|
|
|
|
// Get a setting by key
|
|
app.get('/api/settings/:key', (req, res) => {
|
|
try {
|
|
const { key } = req.params;
|
|
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
|
const result = stmt.get(key);
|
|
|
|
if (!result) {
|
|
return res.status(404).json({ error: 'Setting not found' });
|
|
}
|
|
|
|
res.json({ key, value: result.value });
|
|
} catch (error) {
|
|
console.error('Error getting setting:', error);
|
|
res.status(500).json({ error: 'Failed to get setting' });
|
|
}
|
|
});
|
|
|
|
// Set a setting
|
|
app.post('/api/settings', (req, res) => {
|
|
try {
|
|
const { key, value } = req.body;
|
|
|
|
if (!key || value === undefined) {
|
|
return res.status(400).json({ error: 'Key and value are required' });
|
|
}
|
|
|
|
const stmt = db.prepare(`
|
|
INSERT INTO settings (key, value, updated_at)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`);
|
|
|
|
stmt.run(key, value);
|
|
res.json({ key, value });
|
|
} catch (error) {
|
|
console.error('Error setting setting:', error);
|
|
res.status(500).json({ error: 'Failed to set setting' });
|
|
}
|
|
});
|
|
|
|
// Get all settings
|
|
app.get('/api/settings', (req, res) => {
|
|
try {
|
|
const stmt = db.prepare('SELECT key, value FROM settings');
|
|
const settings = stmt.all();
|
|
|
|
const result = {};
|
|
settings.forEach(s => {
|
|
result[s.key] = s.value;
|
|
});
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('Error getting settings:', error);
|
|
res.status(500).json({ error: 'Failed to get settings' });
|
|
}
|
|
});
|
|
|
|
// Get version/commit info
|
|
app.get('/api/version', (req, res) => {
|
|
const commitHash = process.env.COMMIT_HASH || 'dev';
|
|
const buildDate = process.env.BUILD_DATE || new Date().toISOString();
|
|
|
|
res.json({
|
|
commit: commitHash,
|
|
buildDate: buildDate
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`Server is running on http://localhost:${PORT}`);
|
|
});
|