Allow manual time entry.
This commit is contained in:
92
app.py
92
app.py
@@ -1580,6 +1580,98 @@ def resume_entry(entry_id):
|
|||||||
'total_break_duration': entry_to_resume.total_break_duration
|
'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)
|
@app.errorhandler(404)
|
||||||
def page_not_found(e):
|
def page_not_found(e):
|
||||||
return render_template('404.html'), 404
|
return render_template('404.html'), 404
|
||||||
|
|||||||
@@ -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>
|
<textarea id="work-notes" name="notes" rows="2" placeholder="What are you working on?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button id="arrive-btn" class="arrive-btn">Arrive</button>
|
<button id="arrive-btn" class="arrive-btn">Arrive</button>
|
||||||
|
<button id="manual-entry-btn" class="manual-entry-btn">Add Manual Entry</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -181,8 +182,113 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Time Entry Modal -->
|
||||||
|
<div id="manual-entry-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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
|
// Edit entry functionality
|
||||||
document.querySelectorAll('.edit-entry-btn').forEach(button => {
|
document.querySelectorAll('.edit-entry-btn').forEach(button => {
|
||||||
button.addEventListener('click', function() {
|
button.addEventListener('click', function() {
|
||||||
@@ -402,6 +508,74 @@ Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('regist
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.25rem;
|
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>
|
</style>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user