Allow manual time entry.

This commit is contained in:
2025-07-02 15:57:51 +02:00
committed by Jens Luedicke
parent 5099b7a419
commit 0db0531fea
2 changed files with 266 additions and 0 deletions

92
app.py
View File

@@ -1580,6 +1580,98 @@ def resume_entry(entry_id):
'total_break_duration': entry_to_resume.total_break_duration
})
@app.route('/api/manual-entry', methods=['POST'])
@login_required
def manual_entry():
try:
data = request.get_json()
# Extract data from request
project_id = data.get('project_id')
start_date = data.get('start_date')
start_time = data.get('start_time')
end_date = data.get('end_date')
end_time = data.get('end_time')
break_minutes = int(data.get('break_minutes', 0))
notes = data.get('notes', '')
# Validate required fields
if not all([start_date, start_time, end_date, end_time]):
return jsonify({'error': 'Start and end date/time are required'}), 400
# Parse datetime strings
try:
arrival_datetime = datetime.strptime(f"{start_date} {start_time}", '%Y-%m-%d %H:%M:%S')
departure_datetime = datetime.strptime(f"{end_date} {end_time}", '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
# Try without seconds if parsing fails
arrival_datetime = datetime.strptime(f"{start_date} {start_time}:00", '%Y-%m-%d %H:%M:%S')
departure_datetime = datetime.strptime(f"{end_date} {end_time}:00", '%Y-%m-%d %H:%M:%S')
except ValueError:
return jsonify({'error': 'Invalid date/time format'}), 400
# Validate that end time is after start time
if departure_datetime <= arrival_datetime:
return jsonify({'error': 'End time must be after start time'}), 400
# Validate project access if project is specified
if project_id:
project = Project.query.get(project_id)
if not project or not project.is_user_allowed(g.user):
return jsonify({'error': 'Invalid or unauthorized project'}), 403
# Check for overlapping entries for this user
overlapping_entry = TimeEntry.query.filter(
TimeEntry.user_id == g.user.id,
TimeEntry.departure_time.isnot(None),
TimeEntry.arrival_time < departure_datetime,
TimeEntry.departure_time > arrival_datetime
).first()
if overlapping_entry:
return jsonify({
'error': 'This time entry overlaps with an existing entry'
}), 400
# Calculate total duration in seconds
total_duration = int((departure_datetime - arrival_datetime).total_seconds())
break_duration_seconds = break_minutes * 60
# Validate break duration doesn't exceed total duration
if break_duration_seconds >= total_duration:
return jsonify({'error': 'Break duration cannot exceed total work duration'}), 400
# Calculate work duration (total duration minus breaks)
work_duration = total_duration - break_duration_seconds
# Create the manual time entry
new_entry = TimeEntry(
user_id=g.user.id,
arrival_time=arrival_datetime,
departure_time=departure_datetime,
duration=work_duration,
total_break_duration=break_duration_seconds,
project_id=int(project_id) if project_id else None,
notes=notes,
is_paused=False,
pause_start_time=None
)
db.session.add(new_entry)
db.session.commit()
return jsonify({
'success': True,
'message': 'Manual time entry added successfully',
'entry_id': new_entry.id
})
except Exception as e:
logger.error(f"Error creating manual time entry: {str(e)}")
db.session.rollback()
return jsonify({'error': 'An error occurred while creating the time entry'}), 500
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404

View File

@@ -61,6 +61,7 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
<textarea id="work-notes" name="notes" rows="2" placeholder="What are you working on?"></textarea>
</div>
<button id="arrive-btn" class="arrive-btn">Arrive</button>
<button id="manual-entry-btn" class="manual-entry-btn">Add Manual Entry</button>
</div>
</div>
{% endif %}
@@ -181,8 +182,113 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
</div>
</div>
<!-- Manual Time Entry Modal -->
<div id="manual-entry-modal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h3>Add Manual Time Entry</h3>
<form id="manual-entry-form">
<div class="form-group">
<label for="manual-project-select">Project (Optional):</label>
<select id="manual-project-select" name="project_id">
<option value="">No specific project</option>
{% for project in available_projects %}
<option value="{{ project.id }}">{{ project.code }} - {{ project.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="manual-start-date">Start Date:</label>
<input type="date" id="manual-start-date" required>
</div>
<div class="form-group">
<label for="manual-start-time">Start Time:</label>
<input type="time" id="manual-start-time" required step="1">
</div>
<div class="form-group">
<label for="manual-end-date">End Date:</label>
<input type="date" id="manual-end-date" required>
</div>
<div class="form-group">
<label for="manual-end-time">End Time:</label>
<input type="time" id="manual-end-time" required step="1">
</div>
<div class="form-group">
<label for="manual-break-minutes">Break Duration (minutes):</label>
<input type="number" id="manual-break-minutes" min="0" value="0" placeholder="Break time in minutes">
</div>
<div class="form-group">
<label for="manual-notes">Notes (Optional):</label>
<textarea id="manual-notes" name="notes" rows="3" placeholder="Description of work performed"></textarea>
</div>
<button type="submit" class="btn">Add Entry</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Manual entry functionality
document.getElementById('manual-entry-btn').addEventListener('click', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('manual-start-date').value = today;
document.getElementById('manual-end-date').value = today;
document.getElementById('manual-entry-modal').style.display = 'block';
});
// Manual entry form submission
document.getElementById('manual-entry-form').addEventListener('submit', function(e) {
e.preventDefault();
const projectId = document.getElementById('manual-project-select').value || null;
const startDate = document.getElementById('manual-start-date').value;
const startTime = document.getElementById('manual-start-time').value;
const endDate = document.getElementById('manual-end-date').value;
const endTime = document.getElementById('manual-end-time').value;
const breakMinutes = parseInt(document.getElementById('manual-break-minutes').value) || 0;
const notes = document.getElementById('manual-notes').value;
// Validate end time is after start time
const startDateTime = new Date(`${startDate}T${startTime}`);
const endDateTime = new Date(`${endDate}T${endTime}`);
if (endDateTime <= startDateTime) {
alert('End time must be after start time');
return;
}
// Send request to create manual entry
fetch('/api/manual-entry', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
project_id: projectId,
start_date: startDate,
start_time: startTime,
end_date: endDate,
end_time: endTime,
break_minutes: breakMinutes,
notes: notes
}),
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('manual-entry-modal').style.display = 'none';
location.reload(); // Refresh to show new entry
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while adding the manual entry');
});
});
// Edit entry functionality
document.querySelectorAll('.edit-entry-btn').forEach(button => {
button.addEventListener('click', function() {
@@ -402,6 +508,74 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
display: block;
margin-top: 0.25rem;
}
.manual-entry-btn {
background: #17a2b8;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
margin-left: 1rem;
transition: background-color 0.2s ease;
}
.manual-entry-btn:hover {
background: #138496;
}
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 8px;
width: 500px;
max-width: 90%;
max-height: 80%;
overflow-y: auto;
}
.modal .form-group {
margin-bottom: 1rem;
}
.modal label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.modal input,
.modal select,
.modal textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
box-sizing: border-box;
}
.modal input:focus,
.modal select:focus,
.modal textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
</style>
{% endblock %}