mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-19 10:50:11 -06:00
Merge pull request #23 from DRYTRIX/develop
feat: add time-entry editing; improve invoices/PDF; harden Docker startup
This commit is contained in:
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Enforce LF endings for executable scripts to avoid /usr/bin/env CRLF issues
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
|
||||
# Optional: keep everything else automatic
|
||||
* text=auto
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -10,6 +10,8 @@ ENV FLASK_ENV=production
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
tzdata \
|
||||
bash \
|
||||
dos2unix \
|
||||
# Network tools for debugging
|
||||
iproute2 \
|
||||
net-tools \
|
||||
@@ -51,8 +53,13 @@ RUN mkdir -p /app/app/static/uploads/logos /app/static/uploads/logos && \
|
||||
# Copy the startup script and ensure it's executable
|
||||
COPY docker/start-fixed.py /app/start.py
|
||||
|
||||
# Make startup scripts executable
|
||||
RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh
|
||||
# Fix line endings for the startup and entrypoint scripts
|
||||
RUN dos2unix /app/start.py /app/docker/entrypoint_fixed.sh /app/docker/entrypoint.sh /app/docker/entrypoint_simple.sh 2>/dev/null || true
|
||||
|
||||
# Make startup scripts executable and ensure proper line endings
|
||||
RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/init-database-enhanced.py /app/docker/verify-database.py /app/docker/test-db.py /app/docker/test-routing.py /app/docker/entrypoint.sh /app/docker/entrypoint_fixed.sh /app/docker/entrypoint_simple.sh /app/docker/entrypoint.py /app/docker/startup_with_migration.py /app/docker/test_db_connection.py /app/docker/debug_startup.sh /app/docker/simple_test.sh && \
|
||||
ls -la /app/docker/entrypoint.py && \
|
||||
head -5 /app/docker/entrypoint.py
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 timetracker && \
|
||||
@@ -62,6 +69,10 @@ RUN useradd -m -u 1000 timetracker && \
|
||||
RUN ls -la /app/start.py && \
|
||||
head -1 /app/start.py
|
||||
|
||||
# Verify entrypoint script exists and is accessible
|
||||
RUN ls -la /app/docker/entrypoint.py && \
|
||||
head -1 /app/docker/entrypoint.py
|
||||
|
||||
USER timetracker
|
||||
|
||||
# Expose port
|
||||
@@ -71,8 +82,8 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/_health || exit 1
|
||||
|
||||
# Set the entrypoint
|
||||
# Set the entrypoint back to the fixed shell script
|
||||
ENTRYPOINT ["/app/docker/entrypoint_fixed.sh"]
|
||||
|
||||
# Run the application
|
||||
# Run the application via python to avoid shebang/CRLF issues
|
||||
CMD ["python", "/app/start.py"]
|
||||
|
||||
@@ -16,7 +16,6 @@ A comprehensive web-based time tracking application built with Flask, featuring
|
||||
<div align="center">
|
||||
<img src="assets/screenshots/Reports.png" alt="Reports" width="300" style="margin: 10px;">
|
||||
<img src="assets/screenshots/VisualAnalytics.png" alt="Visual Analytics" width="300" style="margin: 10px;">
|
||||
<img src="assets/screenshots/Task_Management.png" alt="Task Management" width="300" style="margin: 10px;">
|
||||
<img src="assets/screenshots/Admin.png" alt="Admin Panel" width="300" style="margin: 10px;">
|
||||
</div>
|
||||
|
||||
@@ -433,7 +432,7 @@ See `docs/CONTRIBUTING.md` for detailed guidelines.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the `docs/LICENSE` file for details.
|
||||
This project is licensed under the GNU General Public License v3.0 — see `LICENSE` for details.
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ class Invoice(db.Model):
|
||||
client_name = db.Column(db.String(200), nullable=False)
|
||||
client_email = db.Column(db.String(200), nullable=True)
|
||||
client_address = db.Column(db.Text, nullable=True)
|
||||
# Link to clients table (enforced by DB schema)
|
||||
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
|
||||
|
||||
# Invoice details
|
||||
issue_date = db.Column(db.Date, nullable=False, default=datetime.utcnow().date)
|
||||
@@ -36,15 +38,17 @@ class Invoice(db.Model):
|
||||
|
||||
# Relationships
|
||||
project = db.relationship('Project', backref='invoices')
|
||||
client = db.relationship('Client', backref='invoices')
|
||||
creator = db.relationship('User', backref='created_invoices')
|
||||
items = db.relationship('InvoiceItem', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def __init__(self, invoice_number, project_id, client_name, due_date, created_by, **kwargs):
|
||||
def __init__(self, invoice_number, project_id, client_name, due_date, created_by, client_id, **kwargs):
|
||||
self.invoice_number = invoice_number
|
||||
self.project_id = project_id
|
||||
self.client_name = client_name
|
||||
self.due_date = due_date
|
||||
self.created_by = created_by
|
||||
self.client_id = client_id
|
||||
|
||||
# Set optional fields
|
||||
self.client_email = kwargs.get('client_email')
|
||||
@@ -89,6 +93,7 @@ class Invoice(db.Model):
|
||||
'client_name': self.client_name,
|
||||
'client_email': self.client_email,
|
||||
'client_address': self.client_address,
|
||||
'client_id': self.client_id,
|
||||
'issue_date': self.issue_date.isoformat() if self.issue_date else None,
|
||||
'due_date': self.due_date.isoformat() if self.due_date else None,
|
||||
'status': self.status,
|
||||
|
||||
@@ -3,6 +3,9 @@ from flask_login import login_required, current_user
|
||||
from app import db, socketio
|
||||
from app.models import User, Project, TimeEntry, Settings, Task
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.timezone import parse_local_datetime, utc_to_local
|
||||
from app.models.time_entry import local_now
|
||||
import json
|
||||
|
||||
api_bp = Blueprint('api', __name__)
|
||||
@@ -164,8 +167,15 @@ def get_entries():
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# Ensure frontend receives project_name like other endpoints
|
||||
entries_payload = []
|
||||
for entry in entries.items:
|
||||
e = entry.to_dict()
|
||||
e['project_name'] = e.get('project') or (entry.project.name if entry.project else None)
|
||||
entries_payload.append(e)
|
||||
|
||||
return jsonify({
|
||||
'entries': [entry.to_dict() for entry in entries.items],
|
||||
'entries': entries_payload,
|
||||
'total': entries.total,
|
||||
'pages': entries.pages,
|
||||
'current_page': entries.page,
|
||||
@@ -182,6 +192,40 @@ def get_projects():
|
||||
'projects': [project.to_dict() for project in projects]
|
||||
})
|
||||
|
||||
@api_bp.route('/api/projects/<int:project_id>/tasks')
|
||||
@login_required
|
||||
def get_project_tasks(project_id):
|
||||
"""Get tasks for a specific project"""
|
||||
# Check if project exists and is active
|
||||
project = Project.query.filter_by(id=project_id, status='active').first()
|
||||
if not project:
|
||||
return jsonify({'error': 'Project not found or inactive'}), 404
|
||||
|
||||
# Get tasks for the project
|
||||
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'tasks': [{
|
||||
'id': task.id,
|
||||
'name': task.name,
|
||||
'description': task.description,
|
||||
'status': task.status,
|
||||
'priority': task.priority
|
||||
} for task in tasks]
|
||||
})
|
||||
|
||||
# Fetch a single time entry (details for edit modal)
|
||||
@api_bp.route('/api/entry/<int:entry_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_entry(entry_id):
|
||||
entry = TimeEntry.query.get_or_404(entry_id)
|
||||
if entry.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
payload = entry.to_dict()
|
||||
payload['project_name'] = entry.project.name if entry.project else None
|
||||
return jsonify(payload)
|
||||
|
||||
@api_bp.route('/api/users')
|
||||
@login_required
|
||||
def get_users():
|
||||
@@ -235,25 +279,77 @@ def update_entry(entry_id):
|
||||
if entry.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
# Update fields
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Optional: project change (admin only)
|
||||
new_project_id = data.get('project_id')
|
||||
if new_project_id is not None and current_user.is_admin:
|
||||
if new_project_id != entry.project_id:
|
||||
project = Project.query.filter_by(id=new_project_id, status='active').first()
|
||||
if not project:
|
||||
return jsonify({'error': 'Invalid project'}), 400
|
||||
entry.project_id = new_project_id
|
||||
|
||||
# Optional: start/end time updates (admin only for safety)
|
||||
# Accept HTML datetime-local format: YYYY-MM-DDTHH:MM
|
||||
def parse_dt_local(dt_str):
|
||||
if not dt_str:
|
||||
return None
|
||||
try:
|
||||
if 'T' in dt_str:
|
||||
date_part, time_part = dt_str.split('T', 1)
|
||||
else:
|
||||
date_part, time_part = dt_str.split(' ', 1)
|
||||
# Parse as UTC-aware then convert to local naive to match model storage
|
||||
parsed_utc = parse_local_datetime(date_part, time_part)
|
||||
parsed_local_aware = utc_to_local(parsed_utc)
|
||||
return parsed_local_aware.replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if current_user.is_admin:
|
||||
start_time_str = data.get('start_time')
|
||||
end_time_str = data.get('end_time')
|
||||
|
||||
if start_time_str:
|
||||
parsed_start = parse_dt_local(start_time_str)
|
||||
if not parsed_start:
|
||||
return jsonify({'error': 'Invalid start time format'}), 400
|
||||
entry.start_time = parsed_start
|
||||
|
||||
if end_time_str is not None:
|
||||
if end_time_str == '' or end_time_str is False:
|
||||
entry.end_time = None
|
||||
entry.duration_seconds = None
|
||||
else:
|
||||
parsed_end = parse_dt_local(end_time_str)
|
||||
if not parsed_end:
|
||||
return jsonify({'error': 'Invalid end time format'}), 400
|
||||
if parsed_end <= (entry.start_time or parsed_end):
|
||||
return jsonify({'error': 'End time must be after start time'}), 400
|
||||
entry.end_time = parsed_end
|
||||
# Recalculate duration
|
||||
entry.calculate_duration()
|
||||
|
||||
# Notes, tags, billable (both admin and owner can change)
|
||||
if 'notes' in data:
|
||||
entry.notes = data['notes'].strip() if data['notes'] else None
|
||||
|
||||
|
||||
if 'tags' in data:
|
||||
entry.tags = data['tags'].strip() if data['tags'] else None
|
||||
|
||||
|
||||
if 'billable' in data:
|
||||
entry.billable = bool(data['billable'])
|
||||
|
||||
entry.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'entry': entry.to_dict()
|
||||
})
|
||||
|
||||
# Prefer local time for updated_at per project preference
|
||||
entry.updated_at = local_now()
|
||||
|
||||
if not safe_commit('api_update_entry', {'entry_id': entry_id}):
|
||||
return jsonify({'error': 'Database error while updating entry'}), 500
|
||||
|
||||
payload = entry.to_dict()
|
||||
payload['project_name'] = entry.project.name if entry.project else None
|
||||
return jsonify({'success': True, 'entry': payload})
|
||||
|
||||
@api_bp.route('/api/entry/<int:entry_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
|
||||
@@ -85,6 +85,7 @@ def create_invoice():
|
||||
client_name=client_name,
|
||||
due_date=due_date,
|
||||
created_by=current_user.id,
|
||||
client_id=project.client_id,
|
||||
client_email=client_email,
|
||||
client_address=client_address,
|
||||
tax_rate=tax_rate,
|
||||
@@ -393,7 +394,7 @@ def export_invoice_pdf(invoice_id):
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
flash('You do not have permission to export this invoice', 'error')
|
||||
return redirect(url_for('invoices.list_invoices'))
|
||||
return redirect(request.referrer or url_for('invoices.list_invoices'))
|
||||
|
||||
try:
|
||||
from app.utils.pdf_generator import InvoicePDFGenerator
|
||||
@@ -414,7 +415,7 @@ def export_invoice_pdf(invoice_id):
|
||||
|
||||
except ImportError:
|
||||
flash('PDF generation is not available. Please install WeasyPrint.', 'error')
|
||||
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
except Exception as e:
|
||||
# Try fallback PDF generator
|
||||
try:
|
||||
@@ -438,7 +439,7 @@ def export_invoice_pdf(invoice_id):
|
||||
|
||||
except Exception as fallback_error:
|
||||
flash(f'PDF generation failed: {str(e)}. Fallback also failed: {str(fallback_error)}', 'error')
|
||||
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
return redirect(request.referrer or url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/duplicate')
|
||||
@login_required
|
||||
@@ -463,6 +464,7 @@ def duplicate_invoice(invoice_id):
|
||||
client_address=original_invoice.client_address,
|
||||
due_date=original_invoice.due_date + timedelta(days=30), # 30 days from original due date
|
||||
created_by=current_user.id,
|
||||
client_id=original_invoice.client_id,
|
||||
tax_rate=original_invoice.tax_rate,
|
||||
notes=original_invoice.notes,
|
||||
terms=original_invoice.terms
|
||||
|
||||
@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, socketio
|
||||
from app.models import User, Project, TimeEntry, Task, Settings
|
||||
from app.utils.timezone import parse_local_datetime
|
||||
from app.utils.timezone import parse_local_datetime, utc_to_local
|
||||
from datetime import datetime
|
||||
import json
|
||||
from app.utils.db import safe_commit
|
||||
@@ -234,13 +234,106 @@ def edit_timer(timer_id):
|
||||
timer.tags = request.form.get('tags', '').strip()
|
||||
timer.billable = request.form.get('billable') == 'on'
|
||||
|
||||
# Admin users can edit additional fields
|
||||
if current_user.is_admin:
|
||||
# Update project if changed
|
||||
new_project_id = request.form.get('project_id', type=int)
|
||||
if new_project_id and new_project_id != timer.project_id:
|
||||
new_project = Project.query.filter_by(id=new_project_id, status='active').first()
|
||||
if new_project:
|
||||
timer.project_id = new_project_id
|
||||
else:
|
||||
flash('Invalid project selected', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=[] if not new_project_id else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all())
|
||||
|
||||
# Update task if changed
|
||||
new_task_id = request.form.get('task_id', type=int)
|
||||
if new_task_id != timer.task_id:
|
||||
if new_task_id:
|
||||
new_task = Task.query.filter_by(id=new_task_id, project_id=timer.project_id).first()
|
||||
if new_task:
|
||||
timer.task_id = new_task_id
|
||||
else:
|
||||
flash('Invalid task selected for the chosen project', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
|
||||
else:
|
||||
timer.task_id = None
|
||||
|
||||
# Update start and end times if provided
|
||||
start_date = request.form.get('start_date')
|
||||
start_time = request.form.get('start_time')
|
||||
end_date = request.form.get('end_date')
|
||||
end_time = request.form.get('end_time')
|
||||
|
||||
if start_date and start_time:
|
||||
try:
|
||||
# Convert parsed UTC-aware to local naive to match model storage
|
||||
parsed_start_utc = parse_local_datetime(start_date, start_time)
|
||||
new_start_time = utc_to_local(parsed_start_utc).replace(tzinfo=None)
|
||||
|
||||
# Validate that start time is not in the future
|
||||
from app.models.time_entry import local_now
|
||||
current_time = local_now()
|
||||
if new_start_time > current_time:
|
||||
flash('Start time cannot be in the future', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
|
||||
|
||||
timer.start_time = new_start_time
|
||||
except ValueError:
|
||||
flash('Invalid start date/time format', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
|
||||
|
||||
if end_date and end_time:
|
||||
try:
|
||||
# Convert parsed UTC-aware to local naive to match model storage
|
||||
parsed_end_utc = parse_local_datetime(end_date, end_time)
|
||||
new_end_time = utc_to_local(parsed_end_utc).replace(tzinfo=None)
|
||||
|
||||
# Validate that end time is after start time
|
||||
if new_end_time <= timer.start_time:
|
||||
flash('End time must be after start time', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
|
||||
|
||||
timer.end_time = new_end_time
|
||||
# Recalculate duration
|
||||
timer.calculate_duration()
|
||||
except ValueError:
|
||||
flash('Invalid end date/time format', 'error')
|
||||
return render_template('timer/edit_timer.html', timer=timer,
|
||||
projects=Project.query.filter_by(status='active').order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all())
|
||||
|
||||
# Update source if provided
|
||||
new_source = request.form.get('source')
|
||||
if new_source in ['manual', 'auto']:
|
||||
timer.source = new_source
|
||||
|
||||
if not safe_commit('edit_timer', {'timer_id': timer.id}):
|
||||
flash('Could not update timer due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
flash('Timer updated successfully', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
return render_template('timer/edit_timer.html', timer=timer)
|
||||
# Get projects and tasks for admin users
|
||||
projects = []
|
||||
tasks = []
|
||||
if current_user.is_admin:
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
if timer.project_id:
|
||||
tasks = Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()
|
||||
|
||||
return render_template('timer/edit_timer.html', timer=timer, projects=projects, tasks=tasks)
|
||||
|
||||
@timer_bp.route('/timer/delete/<int:timer_id>', methods=['POST'])
|
||||
@login_required
|
||||
|
||||
@@ -177,17 +177,19 @@ class MobileForms {
|
||||
handleFormSubmit(event, form) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
// If form is invalid, let browser show native messages and do NOT lock the button
|
||||
if (typeof form.checkValidity === 'function' && !form.checkValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original text if not already stored
|
||||
if (!submitBtn.getAttribute('data-original-text')) {
|
||||
submitBtn.setAttribute('data-original-text', submitBtn.innerHTML);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
|
||||
// Show loading state and allow native submit
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Processing...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Allow form to submit normally
|
||||
// The form will be submitted and page will reload, so we don't need to re-enable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ class InvoicePDFGenerator:
|
||||
except Exception:
|
||||
base_url = None
|
||||
html_doc = HTML(string=html_content, base_url=base_url)
|
||||
css_doc = CSS(string=css_content)
|
||||
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc])
|
||||
css_doc = CSS(string=css_content, font_config=font_config)
|
||||
pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config)
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
@@ -92,12 +92,19 @@ class InvoicePDFGenerator:
|
||||
.small {{ color: var(--muted); font-size: 10pt; }}
|
||||
|
||||
table {{ width: 100%; border-collapse: collapse; margin-top: 4px; }}
|
||||
thead {{ display: table-header-group; }}
|
||||
tfoot {{ display: table-footer-group; }}
|
||||
thead th {{ background: var(--bg-alt); color: var(--muted); font-weight: 700; border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; text-align: left; }}
|
||||
tbody td {{ border: 1px solid var(--border); padding: 10px; font-size: 10.5pt; }}
|
||||
tfoot td {{ border: 1px solid var(--border); padding: 10px; font-weight: 700; }}
|
||||
.num {{ text-align: right; }}
|
||||
.desc {{ width: 50%; }}
|
||||
|
||||
/* Pagination controls */
|
||||
tr, td, th {{ break-inside: avoid; page-break-inside: avoid; }}
|
||||
.card, .invoice-header, .two-col {{ break-inside: avoid; page-break-inside: avoid; }}
|
||||
h4 {{ break-after: avoid; }}
|
||||
|
||||
.totals {{ margin-top: 6px; }}
|
||||
.note {{ margin-top: 10px; }}
|
||||
.footer {{ border-top: 1px solid var(--border); margin-top: 18px; padding-top: 10px; color: var(--muted); font-size: 10pt; }}
|
||||
@@ -232,8 +239,8 @@ class InvoicePDFGenerator:
|
||||
{self._get_time_entry_info_html(item)}
|
||||
</td>
|
||||
<td class="num">{item.quantity:.2f}</td>
|
||||
<td class="num">{item.unit_price:.2f} {self.settings.currency}</td>
|
||||
<td class="num">{item.total_amount:.2f} {self.settings.currency}</td>
|
||||
<td class="num">{self._format_currency(item.unit_price)}</td>
|
||||
<td class="num">{self._format_currency(item.total_amount)}</td>
|
||||
</tr>
|
||||
"""
|
||||
rows.append(row)
|
||||
@@ -254,7 +261,7 @@ class InvoicePDFGenerator:
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td colspan="3" class="num">Subtotal:</td>
|
||||
<td class="num">{self.invoice.subtotal:.2f} {self.settings.currency}</td>
|
||||
<td class="num">{self._format_currency(self.invoice.subtotal)}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
@@ -263,7 +270,7 @@ class InvoicePDFGenerator:
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td colspan="3" class="num">Tax ({self.invoice.tax_rate:.2f}%):</td>
|
||||
<td class="num">{self.invoice.tax_amount:.2f} {self.settings.currency}</td>
|
||||
<td class="num">{self._format_currency(self.invoice.tax_amount)}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
@@ -271,7 +278,7 @@ class InvoicePDFGenerator:
|
||||
rows.append(f"""
|
||||
<tr>
|
||||
<td colspan="3" class="num">Total Amount:</td>
|
||||
<td class="num">{self.invoice.total_amount:.2f} {self.settings.currency}</td>
|
||||
<td class="num">{self._format_currency(self.invoice.total_amount)}</td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
@@ -301,6 +308,13 @@ class InvoicePDFGenerator:
|
||||
return f'<div class="additional-info">{"".join(html_parts)}</div>'
|
||||
return ''
|
||||
|
||||
def _format_currency(self, value):
|
||||
"""Format numeric currency with thousands separators and 2 decimals."""
|
||||
try:
|
||||
return f"{float(value):,.2f} {self.settings.currency}"
|
||||
except Exception:
|
||||
return f"{value} {self.settings.currency}"
|
||||
|
||||
def _get_payment_info_html(self):
|
||||
"""Generate HTML for payment information"""
|
||||
if self.settings.company_bank_info:
|
||||
|
||||
@@ -81,8 +81,8 @@ class InvoicePDFGeneratorFallback:
|
||||
# Build the story (content)
|
||||
story = self._build_story()
|
||||
|
||||
# Build the PDF
|
||||
doc.build(story)
|
||||
# Build the PDF with page numbers
|
||||
doc.build(story, onFirstPage=self._add_page_number, onLaterPages=self._add_page_number)
|
||||
|
||||
# Read the generated PDF
|
||||
with open(tmp_path, 'rb') as f:
|
||||
@@ -216,21 +216,21 @@ class InvoicePDFGeneratorFallback:
|
||||
row = [
|
||||
item.description,
|
||||
f"{item.quantity:.2f}",
|
||||
f"{item.unit_price:.2f} {self.settings.currency}",
|
||||
f"{item.total_amount:.2f} {self.settings.currency}"
|
||||
self._format_currency(item.unit_price),
|
||||
self._format_currency(item.total_amount)
|
||||
]
|
||||
data.append(row)
|
||||
|
||||
# Add totals
|
||||
data.append(['', '', 'Subtotal:', f"{self.invoice.subtotal:.2f} {self.settings.currency}"])
|
||||
data.append(['', '', 'Subtotal:', self._format_currency(self.invoice.subtotal)])
|
||||
|
||||
if self.invoice.tax_rate > 0:
|
||||
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', f"{self.invoice.tax_amount:.2f} {self.settings.currency}"])
|
||||
data.append(['', '', f'Tax ({self.invoice.tax_rate:.2f}%):', self._format_currency(self.invoice.tax_amount)])
|
||||
|
||||
data.append(['', '', 'Total Amount:', f"{self.invoice.total_amount:.2f} {self.settings.currency}"])
|
||||
data.append(['', '', 'Total Amount:', self._format_currency(self.invoice.total_amount)])
|
||||
|
||||
# Create table
|
||||
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm])
|
||||
table = Table(data, colWidths=[9*cm, 3*cm, 3*cm, 3*cm], repeatRows=1)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f8fafc')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#475569')),
|
||||
@@ -247,6 +247,26 @@ class InvoicePDFGeneratorFallback:
|
||||
|
||||
story.append(table)
|
||||
return story
|
||||
|
||||
def _format_currency(self, value):
|
||||
"""Format numeric currency with thousands separators and 2 decimals."""
|
||||
try:
|
||||
return f"{float(value):,.2f} {self.settings.currency}"
|
||||
except Exception:
|
||||
return f"{value} {self.settings.currency}"
|
||||
|
||||
def _add_page_number(self, canv, doc):
|
||||
"""Add page number at the bottom-right of each page."""
|
||||
page_num = canv.getPageNumber()
|
||||
text = f"Page {page_num}"
|
||||
canv.setFont('Helvetica', 9)
|
||||
try:
|
||||
canv.setFillColor(colors.HexColor('#666666'))
|
||||
except Exception:
|
||||
pass
|
||||
x = doc.leftMargin + doc.width
|
||||
y = doc.bottomMargin - 0.5*cm
|
||||
canv.drawRightString(x, y, text)
|
||||
|
||||
def _build_additional_info(self):
|
||||
"""Build additional information section"""
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
This is a placeholder for the enhanced task management screenshot.
|
||||
|
||||
The screenshot should show:
|
||||
|
||||
1. **Modern Header Section**
|
||||
- Card-based header with task icon and description
|
||||
- Clean, professional appearance
|
||||
|
||||
2. **Quick Stats Section**
|
||||
- Cards showing task counts by status (To Do, In Progress, Review, Done)
|
||||
- Color-coded status indicators
|
||||
- Modern card design with hover effects
|
||||
|
||||
3. **Enhanced Filter Section**
|
||||
- Search input with icon
|
||||
- Dropdown filters for status, priority, project, assignee
|
||||
- Apply Filters and Clear buttons
|
||||
- Professional form layout
|
||||
|
||||
4. **Task Cards Grid**
|
||||
- Responsive grid layout of task cards
|
||||
- Each card showing:
|
||||
- Task name and description
|
||||
- Priority badge with color coding
|
||||
- Status badge
|
||||
- Project information
|
||||
- Due date with overdue indicators
|
||||
- Assigned user
|
||||
- Estimated vs actual hours
|
||||
- Quick action buttons
|
||||
- Hover effects and smooth transitions
|
||||
- Priority-based left border colors
|
||||
|
||||
5. **Mobile-Responsive Design**
|
||||
- Cards that stack properly on mobile
|
||||
- Touch-friendly buttons and interactions
|
||||
- Optimized spacing for all screen sizes
|
||||
|
||||
6. **Modern UI Elements**
|
||||
- Bootstrap 5 styling
|
||||
- Custom CSS variables for consistent theming
|
||||
- Smooth animations and transitions
|
||||
- Professional color scheme
|
||||
- Icon integration (Font Awesome)
|
||||
|
||||
The overall appearance should be:
|
||||
- Clean and modern
|
||||
- Professional and business-like
|
||||
- Easy to read and navigate
|
||||
- Visually appealing with good contrast
|
||||
- Consistent with the application's design system
|
||||
|
||||
This screenshot will replace the existing Tasks.png to showcase the new enhanced interface.
|
||||
181
docker/entrypoint.py
Normal file
181
docker/entrypoint.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python entrypoint script for TimeTracker Docker container
|
||||
This avoids shell script line ending issues and provides better error handling
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import traceback
|
||||
import psycopg2
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def log(message):
|
||||
"""Log message with timestamp"""
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
def wait_for_database():
|
||||
"""Wait for database to be ready"""
|
||||
log("Waiting for database to be available...")
|
||||
|
||||
db_url = os.getenv('DATABASE_URL')
|
||||
if not db_url:
|
||||
log("✗ DATABASE_URL environment variable not set")
|
||||
return False
|
||||
|
||||
log(f"Database URL: {db_url}")
|
||||
|
||||
max_attempts = 30
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
log(f"Attempt {attempt}/{max_attempts} to connect to database...")
|
||||
|
||||
try:
|
||||
if db_url.startswith('postgresql'):
|
||||
# Parse connection string
|
||||
if db_url.startswith('postgresql+psycopg2://'):
|
||||
db_url = db_url.replace('postgresql+psycopg2://', '')
|
||||
|
||||
if '@' in db_url:
|
||||
auth_part, rest = db_url.split('@', 1)
|
||||
user, password = auth_part.split(':', 1)
|
||||
if ':' in rest:
|
||||
host_port, database = rest.rsplit('/', 1)
|
||||
if ':' in host_port:
|
||||
host, port = host_port.split(':', 1)
|
||||
else:
|
||||
host, port = host_port, '5432'
|
||||
else:
|
||||
host, port, database = rest, '5432', 'timetracker'
|
||||
else:
|
||||
host, port, database, user, password = 'db', '5432', 'timetracker', 'timetracker', 'timetracker'
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout=5
|
||||
)
|
||||
conn.close()
|
||||
log("✓ PostgreSQL database is available")
|
||||
return True
|
||||
|
||||
elif db_url.startswith('sqlite://'):
|
||||
db_file = db_url.replace('sqlite://', '')
|
||||
if os.path.exists(db_file) or os.access(os.path.dirname(db_file), os.W_OK):
|
||||
log("✓ SQLite database is available")
|
||||
return True
|
||||
else:
|
||||
log("SQLite file not accessible")
|
||||
else:
|
||||
log(f"Unknown database URL format: {db_url}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Database connection failed: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
log(f"Waiting {retry_delay} seconds before next attempt...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
log("✗ Database is not available after maximum retries")
|
||||
return False
|
||||
|
||||
def run_migrations():
|
||||
"""Run database migrations"""
|
||||
log("Checking migrations...")
|
||||
|
||||
try:
|
||||
# Check if migrations directory exists
|
||||
if os.path.exists("/app/migrations"):
|
||||
log("Migrations directory exists, checking status...")
|
||||
|
||||
# Try to apply any pending migrations
|
||||
result = subprocess.run(['flask', 'db', 'upgrade'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
log("✓ Migrations applied successfully")
|
||||
return True
|
||||
else:
|
||||
log(f"⚠ Migration application failed: {result.stderr}")
|
||||
return False
|
||||
else:
|
||||
log("No migrations directory found, initializing...")
|
||||
|
||||
# Initialize migrations
|
||||
result = subprocess.run(['flask', 'db', 'init'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
log("✓ Migrations initialized")
|
||||
|
||||
# Create initial migration
|
||||
result = subprocess.run(['flask', 'db', 'migrate', '-m', 'Initial schema'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
log("✓ Initial migration created")
|
||||
|
||||
# Apply migration
|
||||
result = subprocess.run(['flask', 'db', 'upgrade'],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode == 0:
|
||||
log("✓ Initial migration applied")
|
||||
return True
|
||||
else:
|
||||
log(f"⚠ Initial migration application failed: {result.stderr}")
|
||||
return False
|
||||
else:
|
||||
log(f"⚠ Initial migration creation failed: {result.stderr}")
|
||||
return False
|
||||
else:
|
||||
log(f"⚠ Migration initialization failed: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log("⚠ Migration operation timed out")
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"⚠ Migration error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main entrypoint function"""
|
||||
log("=== TimeTracker Docker Entrypoint ===")
|
||||
|
||||
# Set environment variables
|
||||
os.environ.setdefault('FLASK_APP', '/app/app.py')
|
||||
|
||||
# Wait for database
|
||||
if not wait_for_database():
|
||||
log("✗ Failed to connect to database")
|
||||
sys.exit(1)
|
||||
|
||||
# Run migrations
|
||||
if not run_migrations():
|
||||
log("⚠ Migration issues detected, continuing anyway")
|
||||
|
||||
log("=== Startup Complete ===")
|
||||
log("Starting TimeTracker application...")
|
||||
|
||||
# Execute the command passed to the container
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
os.execv(sys.argv[1], sys.argv[1:])
|
||||
except Exception as e:
|
||||
log(f"✗ Failed to execute command: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Default command
|
||||
try:
|
||||
os.execv('/usr/bin/python', ['python', '/app/start.py'])
|
||||
except Exception as e:
|
||||
log(f"✗ Failed to execute default command: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
136
docker/entrypoint_simple.sh
Normal file
136
docker/entrypoint_simple.sh
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== TimeTracker Docker Container Starting ==="
|
||||
echo "Timestamp: $(date)"
|
||||
echo "Container ID: $(hostname)"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "User: $(whoami)"
|
||||
echo "Python version: $(python --version 2>/dev/null || echo 'Python not available')"
|
||||
echo
|
||||
|
||||
# Function to log messages with timestamp
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# Function to wait for database
|
||||
wait_for_database() {
|
||||
local db_url="$1"
|
||||
local max_retries=30
|
||||
local retry_delay=2
|
||||
|
||||
log "Waiting for database to be available..."
|
||||
log "Database URL: $db_url"
|
||||
|
||||
for attempt in $(seq 1 $max_retries); do
|
||||
log "Attempt $attempt/$max_retries to connect to database..."
|
||||
|
||||
if [[ "$db_url" == postgresql* ]]; then
|
||||
if python -c "
|
||||
import psycopg2
|
||||
import sys
|
||||
try:
|
||||
conn_str = '$db_url'.replace('+psycopg2://', '://')
|
||||
conn = psycopg2.connect(conn_str)
|
||||
conn.close()
|
||||
print('Connection successful')
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f'Connection failed: {e}')
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
log "✓ PostgreSQL database is available"
|
||||
return 0
|
||||
fi
|
||||
elif [[ "$db_url" == sqlite://* ]]; then
|
||||
local db_file="${db_url#sqlite://}"
|
||||
if [[ -f "$db_file" ]] || [[ -w "$(dirname "$db_file")" ]]; then
|
||||
log "✓ SQLite database is available"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Database not ready (attempt $attempt/$max_retries)"
|
||||
if [[ $attempt -lt $max_retries ]]; then
|
||||
log "Waiting $retry_delay seconds before next attempt..."
|
||||
sleep $retry_delay
|
||||
fi
|
||||
done
|
||||
|
||||
log "✗ Database is not available after maximum retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
log "=== TimeTracker Docker Entrypoint ==="
|
||||
|
||||
# Set environment variables
|
||||
export FLASK_APP=${FLASK_APP:-/app/app.py}
|
||||
|
||||
# Get database URL from environment
|
||||
local db_url="${DATABASE_URL}"
|
||||
if [[ -z "$db_url" ]]; then
|
||||
log "✗ DATABASE_URL environment variable not set"
|
||||
log "Available environment variables:"
|
||||
env | grep -E "(DATABASE|FLASK|PYTHON)" | sort
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Database URL: $db_url"
|
||||
|
||||
# Wait for database to be available
|
||||
if ! wait_for_database "$db_url"; then
|
||||
log "✗ Failed to connect to database"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if migrations directory exists
|
||||
if [[ -d "/app/migrations" ]]; then
|
||||
log "Migrations directory exists, checking status..."
|
||||
|
||||
# Try to apply any pending migrations
|
||||
if command -v flask >/dev/null 2>&1; then
|
||||
log "Applying pending migrations..."
|
||||
if flask db upgrade; then
|
||||
log "✓ Migrations applied successfully"
|
||||
else
|
||||
log "⚠ Migration application failed, continuing anyway"
|
||||
fi
|
||||
else
|
||||
log "⚠ Flask CLI not available, skipping migrations"
|
||||
fi
|
||||
else
|
||||
log "No migrations directory found, initializing..."
|
||||
|
||||
if command -v flask >/dev/null 2>&1; then
|
||||
if flask db init; then
|
||||
log "✓ Migrations initialized"
|
||||
if flask db migrate -m "Initial schema"; then
|
||||
log "✓ Initial migration created"
|
||||
if flask db upgrade; then
|
||||
log "✓ Initial migration applied"
|
||||
else
|
||||
log "⚠ Initial migration application failed"
|
||||
fi
|
||||
else
|
||||
log "⚠ Initial migration creation failed"
|
||||
fi
|
||||
else
|
||||
log "⚠ Migration initialization failed"
|
||||
fi
|
||||
else
|
||||
log "⚠ Flask CLI not available, skipping migration setup"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "=== Startup Complete ==="
|
||||
log "Starting TimeTracker application..."
|
||||
|
||||
# Execute the command passed to the container
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
# Run main function with all arguments
|
||||
main "$@"
|
||||
@@ -174,6 +174,7 @@ def get_required_schema():
|
||||
'columns': [
|
||||
'id SERIAL PRIMARY KEY',
|
||||
'invoice_number VARCHAR(50) UNIQUE NOT NULL',
|
||||
'client_id INTEGER NOT NULL',
|
||||
'project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE',
|
||||
'client_name VARCHAR(200) NOT NULL',
|
||||
'client_email VARCHAR(200)',
|
||||
@@ -193,6 +194,7 @@ def get_required_schema():
|
||||
],
|
||||
'indexes': [
|
||||
'CREATE INDEX IF NOT EXISTS idx_invoices_project_id ON invoices(project_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invoices_client_id ON invoices(client_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invoices_issue_date ON invoices(issue_date)'
|
||||
]
|
||||
@@ -338,49 +340,55 @@ def insert_initial_data(engine):
|
||||
# Get admin username from environment
|
||||
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
|
||||
|
||||
# Insert default admin user
|
||||
# Insert default admin user (idempotent via unique username)
|
||||
conn.execute(text(f"""
|
||||
INSERT INTO users (username, role, is_active)
|
||||
VALUES ('{admin_username}', 'admin', true)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
SELECT '{admin_username}', 'admin', true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM users WHERE username = '{admin_username}'
|
||||
);
|
||||
"""))
|
||||
|
||||
# Ensure default client exists
|
||||
# Ensure default client exists (idempotent via unique name)
|
||||
conn.execute(text("""
|
||||
INSERT INTO clients (name, status)
|
||||
VALUES ('Default Client', 'active')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
SELECT 'Default Client', 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clients WHERE name = 'Default Client'
|
||||
);
|
||||
"""))
|
||||
|
||||
# Insert default project (link to default client if possible)
|
||||
# Insert default project linked to default client if not present
|
||||
conn.execute(text("""
|
||||
INSERT INTO projects (name, client_id, description, billable, status)
|
||||
VALUES (
|
||||
'General',
|
||||
(SELECT id FROM clients WHERE name = 'Default Client'),
|
||||
'Default project for general tasks',
|
||||
true,
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
SELECT 'General', c.id, 'Default project for general tasks', true, 'active'
|
||||
FROM clients c
|
||||
WHERE c.name = 'Default Client'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM projects p WHERE p.name = 'General'
|
||||
);
|
||||
"""))
|
||||
|
||||
# Insert default settings
|
||||
|
||||
# Insert default settings only if none exist (singleton semantics)
|
||||
conn.execute(text("""
|
||||
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer,
|
||||
allow_self_register, idle_timeout_minutes, backup_retention_days,
|
||||
backup_time, export_delimiter, allow_analytics,
|
||||
company_name, company_address, company_email, company_phone,
|
||||
company_website, company_logo_filename, company_tax_id,
|
||||
company_bank_info, invoice_prefix, invoice_start_number,
|
||||
invoice_terms, invoice_notes)
|
||||
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', true,
|
||||
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
|
||||
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
|
||||
'Payment is due within 30 days of invoice date.', 'Thank you for your business!')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO settings (
|
||||
timezone, currency, rounding_minutes, single_active_timer,
|
||||
allow_self_register, idle_timeout_minutes, backup_retention_days,
|
||||
backup_time, export_delimiter, allow_analytics,
|
||||
company_name, company_address, company_email, company_phone,
|
||||
company_website, company_logo_filename, company_tax_id,
|
||||
company_bank_info, invoice_prefix, invoice_start_number,
|
||||
invoice_terms, invoice_notes
|
||||
)
|
||||
SELECT 'Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', true,
|
||||
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
|
||||
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
|
||||
'Payment is due within 30 days of invoice date.', 'Thank you for your business!'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM settings
|
||||
);
|
||||
"""))
|
||||
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("✓ Initial data inserted successfully")
|
||||
|
||||
@@ -82,6 +82,7 @@ def create_tables_sql(engine):
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
invoice_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||
client_name VARCHAR(200) NOT NULL,
|
||||
client_email VARCHAR(200),
|
||||
@@ -186,6 +187,7 @@ def create_indexes(engine):
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_user_id ON time_entries(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_project_id ON time_entries(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_client_id ON invoices(client_id);
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -261,20 +263,42 @@ def insert_initial_data(engine):
|
||||
admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0]
|
||||
|
||||
insert_sql = f"""
|
||||
-- Insert default admin user
|
||||
-- Insert default admin user idempotently
|
||||
INSERT INTO users (username, role, is_active)
|
||||
VALUES ('{admin_username}', 'admin', true)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
SELECT '{admin_username}', 'admin', true
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM users WHERE username = '{admin_username}'
|
||||
);
|
||||
|
||||
-- Insert default project
|
||||
-- Ensure default client exists
|
||||
INSERT INTO clients (name, status)
|
||||
SELECT 'Default Client', 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM clients WHERE name = 'Default Client'
|
||||
);
|
||||
|
||||
-- Insert default project idempotently and link to default client
|
||||
INSERT INTO projects (name, client, description, billable, status)
|
||||
VALUES ('General', 'Default Client', 'Default project for general tasks', true, 'active')
|
||||
ON CONFLICT DO NOTHING;
|
||||
SELECT 'General', 'Default Client', 'Default project for general tasks', true, 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM projects WHERE name = 'General'
|
||||
);
|
||||
|
||||
-- Insert default settings
|
||||
INSERT INTO settings (timezone, currency, rounding_minutes, single_active_timer, allow_self_register, idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter, company_name, company_address, company_email, company_phone, company_website, company_logo_filename, company_tax_id, company_bank_info, invoice_prefix, invoice_start_number, invoice_terms, invoice_notes)
|
||||
VALUES ('Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',', 'Your Company Name', 'Your Company Address', 'info@yourcompany.com', '+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000, 'Payment is due within 30 days of invoice date.', 'Thank you for your business!')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
-- Insert default settings only if none exist
|
||||
INSERT INTO settings (
|
||||
timezone, currency, rounding_minutes, single_active_timer, allow_self_register,
|
||||
idle_timeout_minutes, backup_retention_days, backup_time, export_delimiter,
|
||||
company_name, company_address, company_email, company_phone, company_website,
|
||||
company_logo_filename, company_tax_id, company_bank_info, invoice_prefix,
|
||||
invoice_start_number, invoice_terms, invoice_notes
|
||||
)
|
||||
SELECT 'Europe/Rome', 'EUR', 1, true, true, 30, 30, '02:00', ',',
|
||||
'Your Company Name', 'Your Company Address', 'info@yourcompany.com',
|
||||
'+1 (555) 123-4567', 'www.yourcompany.com', '', '', '', 'INV', 1000,
|
||||
'Payment is due within 30 days of invoice date.', 'Thank you for your business!'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM settings
|
||||
);
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
@@ -130,7 +130,7 @@ existing governing policies.
|
||||
|
||||
* [Contributing Guidelines](CONTRIBUTING.md)
|
||||
* [Project Documentation](README.md)
|
||||
* [Community Guidelines](https://github.com/yourusername/TimeTracker/discussions)
|
||||
* [Community Guidelines](https://github.com/drytrix/TimeTracker/discussions)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ This project and everyone participating in it is governed by our [Code of Conduc
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/yourusername/TimeTracker.git
|
||||
git clone https://github.com/drytrix/TimeTracker.git
|
||||
cd TimeTracker
|
||||
```
|
||||
|
||||
|
||||
@@ -24,16 +24,17 @@ This guide explains how to set up and use the public Docker image for TimeTracke
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/TimeTracker.git
|
||||
git clone https://github.com/drytrix/TimeTracker.git
|
||||
cd TimeTracker
|
||||
./deploy-public.sh
|
||||
# Remote production image (latest)
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
git clone https://github.com/yourusername/TimeTracker.git
|
||||
git clone https://github.com/drytrix/TimeTracker.git
|
||||
cd TimeTracker
|
||||
deploy-public.bat
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
#### Option B: Manual Deployment
|
||||
@@ -44,10 +45,7 @@ deploy-public.bat
|
||||
cd TimeTracker
|
||||
```
|
||||
|
||||
2. **Set your GitHub repository:**
|
||||
```bash
|
||||
export GITHUB_REPOSITORY="yourusername/timetracker"
|
||||
```
|
||||
2. (Optional) **Use a specific version tag:** set the tag in `docker-compose.remote.yml`.
|
||||
|
||||
3. **Create environment file:**
|
||||
```bash
|
||||
@@ -57,8 +55,8 @@ deploy-public.bat
|
||||
|
||||
4. **Pull and run the image:**
|
||||
```bash
|
||||
docker pull ghcr.io/$GITHUB_REPOSITORY:latest
|
||||
docker-compose -f docker-compose.public.yml up -d
|
||||
docker pull ghcr.io/drytrix/timetracker:latest
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
@@ -86,11 +84,11 @@ DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
|
||||
|
||||
### Docker Compose Configuration
|
||||
|
||||
The `docker-compose.public.yml` file includes:
|
||||
Use `docker-compose.remote.yml` (production) or `docker-compose.remote-dev.yml` (testing):
|
||||
|
||||
- **TimeTracker App**: Main application container
|
||||
- **PostgreSQL Database**: Persistent data storage
|
||||
- **Caddy Reverse Proxy**: Optional HTTPS support
|
||||
- **App**: `ghcr.io/drytrix/timetracker` image
|
||||
- **PostgreSQL**: Database service with healthcheck
|
||||
- **Ports**: App exposed on 8080 by default
|
||||
|
||||
## 📦 Available Images
|
||||
|
||||
@@ -114,10 +112,10 @@ The public image is automatically updated when you push to the main branch. To u
|
||||
|
||||
```bash
|
||||
# Pull the latest image
|
||||
docker pull ghcr.io/yourusername/timetracker:latest
|
||||
docker pull ghcr.io/drytrix/timetracker:latest
|
||||
|
||||
# Restart the containers
|
||||
docker-compose -f docker-compose.public.yml up -d
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
### Manual Updates
|
||||
@@ -126,11 +124,11 @@ For specific versions:
|
||||
|
||||
```bash
|
||||
# Pull a specific version
|
||||
docker pull ghcr.io/yourusername/timetracker:v1.0.0
|
||||
docker pull ghcr.io/drytrix/timetracker:v1.0.0
|
||||
|
||||
# Update docker-compose.public.yml to use the specific tag
|
||||
# Update docker-compose.remote.yml to use the specific tag
|
||||
# Then restart
|
||||
docker-compose -f docker-compose.public.yml up -d
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
@@ -168,23 +166,23 @@ docker-compose -f docker-compose.public.yml up -d
|
||||
|
||||
```bash
|
||||
# List available images
|
||||
docker images ghcr.io/yourusername/timetracker
|
||||
docker images ghcr.io/drytrix/timetracker
|
||||
|
||||
# Inspect image details
|
||||
docker inspect ghcr.io/yourusername/timetracker:latest
|
||||
docker inspect ghcr.io/drytrix/timetracker:latest
|
||||
```
|
||||
|
||||
#### View Logs
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
docker-compose -f docker-compose.public.yml logs app
|
||||
docker-compose -f docker-compose.remote.yml logs app
|
||||
|
||||
# Database logs
|
||||
docker-compose -f docker-compose.public.yml logs db
|
||||
docker-compose -f docker-compose.remote.yml logs db
|
||||
|
||||
# All logs
|
||||
docker-compose -f docker-compose.public.yml logs -f
|
||||
docker-compose -f docker-compose.remote.yml logs -f
|
||||
```
|
||||
|
||||
#### Health Check
|
||||
@@ -194,7 +192,7 @@ docker-compose -f docker-compose.public.yml logs -f
|
||||
curl http://localhost:8080/_health
|
||||
|
||||
# Check container status
|
||||
docker-compose -f docker-compose.public.yml ps
|
||||
docker-compose -f docker-compose.remote.yml ps
|
||||
```
|
||||
|
||||
## 🔒 Security Considerations
|
||||
@@ -240,10 +238,10 @@ docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
|
||||
```bash
|
||||
# Follow application logs
|
||||
docker-compose -f docker-compose.public.yml logs -f app
|
||||
docker-compose -f docker-compose.remote.yml logs -f app
|
||||
|
||||
# Export logs for analysis
|
||||
docker-compose -f docker-compose.public.yml logs app > timetracker.log
|
||||
docker-compose -f docker-compose.remote.yml logs app > timetracker.log
|
||||
```
|
||||
|
||||
## 🚀 Production Deployment
|
||||
@@ -263,7 +261,7 @@ docker-compose -f docker-compose.public.yml logs app > timetracker.log
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/yourusername/timetracker:v1.0.0
|
||||
image: ghcr.io/drytrix/timetracker:v1.0.0
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ADMIN_USERNAMES=${ADMIN_USERNAMES}
|
||||
|
||||
@@ -13,14 +13,17 @@ This error typically occurs due to one of these issues:
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Use the Fixed Dockerfile (Recommended)
|
||||
### Solution 1: Use the Remote Compose (Recommended)
|
||||
```bash
|
||||
# Use the fixed Dockerfile that addresses all issues
|
||||
docker-compose -f docker-compose.fixed.yml up --build
|
||||
# Use the production remote compose with prebuilt image
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
```
|
||||
|
||||
### Solution 2: Fix the Original Dockerfile
|
||||
The original Dockerfile has been updated to use `start-new.sh` instead of `start-fixed.sh`.
|
||||
### Solution 2: Rebuild Locally
|
||||
The provided `Dockerfile` supports local builds. If you prefer rebuilding:
|
||||
```bash
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
### Solution 3: Manual Fix
|
||||
If you want to fix it manually:
|
||||
@@ -69,18 +72,18 @@ docker-compose build --no-cache
|
||||
```
|
||||
|
||||
## File Structure
|
||||
- `Dockerfile.fixed` - Fixed version addressing all issues
|
||||
- `docker-compose.fixed.yml` - Uses the fixed Dockerfile
|
||||
- `Dockerfile` - Container build file
|
||||
- `docker/start.sh` - Startup wrapper
|
||||
- `docker/start-simple.sh` - Simple, reliable startup script
|
||||
- `docker/start-new.sh` - Enhanced startup script with schema fixes
|
||||
- `docker/start-fixed.sh` - Enhanced startup script with schema fixes
|
||||
|
||||
## Quick Test
|
||||
```bash
|
||||
# Test with the fixed version
|
||||
docker-compose -f docker-compose.fixed.yml up --build
|
||||
# Test remote production image
|
||||
docker-compose -f docker-compose.remote.yml up -d
|
||||
|
||||
# Or test with the original (after fixes)
|
||||
docker-compose up --build
|
||||
# Or build locally
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
## Common Issues and Fixes
|
||||
|
||||
@@ -14,9 +14,10 @@ TimeTracker/
|
||||
├── 📁 .github/ # GitHub workflows and configurations
|
||||
├── 📁 logs/ # Application logs (with .gitkeep)
|
||||
├── 🐳 Dockerfile # Main Dockerfile
|
||||
├── 🐳 Dockerfile.simple # Simple container Dockerfile
|
||||
├── 📄 docker-compose.simple.yml # Simple container setup
|
||||
├── 📄 docker-compose.public.yml # Public container setup
|
||||
├── 🐳 Dockerfile # Main Dockerfile
|
||||
├── 📄 docker-compose.yml # Local development compose
|
||||
├── 📄 docker-compose.remote.yml # Remote/production compose (ghcr.io)
|
||||
├── 📄 docker-compose.remote-dev.yml # Remote dev/testing compose (ghcr.io)
|
||||
├── 📄 requirements.txt # Python dependencies
|
||||
├── 📄 app.py # Application entry point
|
||||
├── 📄 env.example # Environment variables template
|
||||
@@ -80,17 +81,20 @@ TimeTracker/
|
||||
|
||||
## 🚀 Deployment Options
|
||||
|
||||
### 1. Simple Container (Recommended)
|
||||
- **File**: `docker-compose.simple.yml`
|
||||
- **Dockerfile**: `Dockerfile.simple`
|
||||
- **Features**: All-in-one with PostgreSQL database
|
||||
### 1. Local Development
|
||||
- **File**: `docker-compose.yml`
|
||||
- **Image**: Built from local source
|
||||
- **Use case**: Developer workstation
|
||||
|
||||
### 2. Remote/Production
|
||||
- **File**: `docker-compose.remote.yml`
|
||||
- **Image**: `ghcr.io/drytrix/timetracker:latest` (or versioned tag)
|
||||
- **Use case**: Production deployment
|
||||
|
||||
### 2. Public Container
|
||||
- **File**: `docker-compose.public.yml`
|
||||
- **Dockerfile**: `Dockerfile`
|
||||
- **Features**: External database configuration
|
||||
- **Use case**: Development and testing
|
||||
### 3. Remote Dev/Testing
|
||||
- **File**: `docker-compose.remote-dev.yml`
|
||||
- **Image**: `ghcr.io/drytrix/timetracker:development`
|
||||
- **Use case**: Pre-release testing
|
||||
|
||||
## 📚 Documentation Files
|
||||
|
||||
@@ -135,9 +139,9 @@ The Task Management feature is fully integrated into the application with automa
|
||||
|
||||
## 🎯 Getting Started
|
||||
|
||||
1. **Choose deployment type**: Simple container (recommended) or public container
|
||||
1. **Choose deployment type**: Local dev, remote, or remote-dev
|
||||
2. **Follow README.md**: Complete setup instructions
|
||||
3. **Use appropriate compose file**: `docker-compose.simple.yml` or `docker-compose.public.yml`
|
||||
3. **Use appropriate compose file**: `docker-compose.yml`, `docker-compose.remote.yml`, or `docker-compose.remote-dev.yml`
|
||||
4. **Configure timezone**: Access admin settings to set your local timezone
|
||||
|
||||
## 🔍 File Purposes
|
||||
|
||||
@@ -20,7 +20,7 @@ Welcome to the TimeTracker documentation. This directory contains comprehensive
|
||||
- **[INVOICE_FEATURE_README.md](INVOICE_FEATURE_README.md)** - Invoice system documentation
|
||||
- **[ENHANCED_INVOICE_SYSTEM_README.md](ENHANCED_INVOICE_SYSTEM_README.md)** - Advanced invoice features
|
||||
- **[INVOICE_INTERFACE_IMPROVEMENTS.md](INVOICE_INTERFACE_IMPROVEMENTS.md)** - Invoice UI improvements
|
||||
- **[ANALYTICS_FEATURE.md](ANALYTICS_FEATURE.md)** - Analytics and reporting features
|
||||
- Analytics and reporting features (see the Reports section in the app)
|
||||
- **[MOBILE_IMPROVEMENTS.md](MOBILE_IMPROVEMENTS.md)** - Mobile interface enhancements
|
||||
|
||||
### 🛠️ Technical Details
|
||||
|
||||
@@ -168,13 +168,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set tax_present = invoice.tax_rate > 0 %}
|
||||
<!-- Totals Summary -->
|
||||
<div class="row mb-4">
|
||||
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
|
||||
<div class="card summary-card border-0 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="summary-icon bg-primary text-white me-3"><i class="fas fa-coins"></i></div>
|
||||
<div>
|
||||
<div class="summary-label">Subtotal</div>
|
||||
<div class="summary-value">{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if tax_present %}
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card border-0 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="summary-icon bg-warning text-white me-3"><i class="fas fa-percent"></i></div>
|
||||
<div>
|
||||
<div class="summary-label">Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)</div>
|
||||
<div class="summary-value">{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="{{ 'col-md-4' if tax_present else 'col-md-6' }}">
|
||||
<div class="card summary-card border-0 shadow-sm">
|
||||
<div class="card-body d-flex align-items-center">
|
||||
<div class="summary-icon bg-success text-white me-3"><i class="fas fa-sack-dollar"></i></div>
|
||||
<div>
|
||||
<div class="summary-label">Total Amount</div>
|
||||
<div class="summary-value text-success">{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Invoice Items -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<h6 class="m-0 font-weight-bold text-primary d-flex align-items-center">
|
||||
<i class="fas fa-list me-2"></i>Invoice Items
|
||||
<span class="badge badge-soft-secondary ms-2">{{ invoice.items.count() }} items</span>
|
||||
</h6>
|
||||
{% if invoice.status == 'draft' %}
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
@@ -479,6 +520,29 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.summary-card .summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.summary-card .summary-value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-card .summary-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
{% endif %}
|
||||
<h1 class="h2 mb-2">Help & Documentation</h1>
|
||||
<p class="text-muted">TimeTracker by <strong>DryTrix</strong></p>
|
||||
<div class="d-flex justify-content-center gap-2 mt-2">
|
||||
<a href="{{ url_for('timer.manual_entry') }}" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-play"></i> Log Time
|
||||
</a>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-chart-bar"></i> View Reports
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-info btn-sm">
|
||||
<i class="fas fa-sliders-h"></i> System Settings
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started -->
|
||||
@@ -54,9 +67,9 @@
|
||||
<h5>Navigation</h5>
|
||||
<ul>
|
||||
<li><strong>Dashboard:</strong> Overview of your current timer and recent activity</li>
|
||||
<li><strong>Timer:</strong> Start, stop, and manage time tracking</li>
|
||||
<li><strong>Projects:</strong> View and manage projects (admin only)</li>
|
||||
<li><strong>Reports:</strong> Generate time reports and export data</li>
|
||||
<li><strong>Timer:</strong> Start, stop, and manage time tracking (<a href="{{ url_for('timer.manual_entry') }}">Log Time</a>)</li>
|
||||
<li><strong>Projects:</strong> View and manage projects{% if current_user.is_admin %} (<a href="{{ url_for('projects.list_projects') }}">Open Projects</a>){% endif %}</li>
|
||||
<li><strong>Reports:</strong> Generate time reports and export data (<a href="{{ url_for('reports.reports') }}">Open Reports</a>)</li>
|
||||
{% if current_user.is_admin %}
|
||||
<li><strong>Admin:</strong> Manage users and system settings</li>
|
||||
{% endif %}
|
||||
@@ -153,7 +166,7 @@
|
||||
</ul>
|
||||
|
||||
<h5>Exporting Data</h5>
|
||||
<p>Use the CSV export feature to download time entries for use in external tools like Excel or accounting software.</p>
|
||||
<p>Use the CSV export feature to download time entries for external tools. Find it on the Reports page.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}New Project - {{ app_name }}{% endblock %}
|
||||
{% block title %}Create Project - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="container mt-4">
|
||||
<!-- Header -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
|
||||
<li class="breadcrumb-item active">New</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-project-diagram text-primary"></i> New Project
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Projects
|
||||
</a>
|
||||
<div class="card mobile-card">
|
||||
<div class="card-body py-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 48px; height: 48px;">
|
||||
<i class="fas fa-project-diagram text-primary fa-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-1">Create New Project</h1>
|
||||
<p class="text-muted mb-0">Set up a new project to organize your work and track time effectively</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Project Form -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Project Information
|
||||
</h5>
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-edit me-2 text-primary"></i>Project Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate>
|
||||
<div class="row">
|
||||
<form method="POST" action="{{ url_for('projects.create_project') }}" novalidate id="createProjectForm">
|
||||
<!-- Project Name and Client -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Project Name *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required value="{{ request.form.get('name','') }}">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
<i class="fas fa-tag me-2 text-primary"></i>Project Name <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="name" name="name" required
|
||||
value="{{ request.form.get('name','') }}" placeholder="Enter a descriptive project name">
|
||||
<small class="form-text text-muted">Choose a clear, descriptive name that explains the project scope</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="client_id" class="form-label">Client *</label>
|
||||
<select class="form-control" id="client_id" name="client_id" required>
|
||||
<label for="client_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-user me-2 text-info"></i>Client <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select form-select-lg" id="client_id" name="client_id" required>
|
||||
<option value="">Select a client...</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}"
|
||||
@@ -66,54 +70,312 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ request.form.get('description','') }}</textarea>
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>Description
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4"
|
||||
placeholder="Provide detailed information about the project, objectives, and deliverables...">{{ request.form.get('description','') }}</textarea>
|
||||
<small class="form-text text-muted">Optional: Add context, objectives, or specific requirements for the project</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Billing Settings -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %}>
|
||||
<label class="form-check-label" for="billable">Billable</label>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-2 text-success"></i>Billable Project
|
||||
</label>
|
||||
<small class="form-text text-muted d-block">Enable billing for this project</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="hourly_rate" class="form-label">Hourly Rate</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate','') }}" placeholder="e.g. 75.00">
|
||||
<div class="form-text">Leave empty for non-billable projects</div>
|
||||
<label for="hourly_rate" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-2 text-warning"></i>Hourly Rate
|
||||
</label>
|
||||
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate"
|
||||
value="{{ request.form.get('hourly_rate','') }}" placeholder="e.g. 75.00">
|
||||
<small class="form-text text-muted">Leave empty for non-billable projects</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="billing_ref" class="form-label">Billing Reference</label>
|
||||
<input type="text" class="form-control" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref','') }}" placeholder="Optional">
|
||||
<!-- Billing Reference -->
|
||||
<div class="mb-4">
|
||||
<label for="billing_ref" class="form-label fw-semibold">
|
||||
<i class="fas fa-receipt me-2 text-secondary"></i>Billing Reference
|
||||
</label>
|
||||
<input type="text" class="form-control" id="billing_ref" name="billing_ref"
|
||||
value="{{ request.form.get('billing_ref','') }}" placeholder="PO number, contract reference, etc.">
|
||||
<small class="form-text text-muted">Optional: Add a reference number or identifier for billing purposes</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Project
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex gap-3 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-save me-2"></i>Create Project
|
||||
</button>
|
||||
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Help -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Project Creation Tips -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb me-2 text-warning"></i>Project Creation Tips
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-check text-primary fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Clear Naming</small>
|
||||
<small class="text-muted">Use descriptive names that clearly indicate the project's purpose</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-dollar-sign text-success fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Billing Setup</small>
|
||||
<small class="text-muted">Set appropriate hourly rates based on project complexity and client budget</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-info-circle text-info fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Detailed Description</small>
|
||||
<small class="text-muted">Include project objectives, deliverables, and key requirements</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user text-warning fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Client Selection</small>
|
||||
<small class="text-muted">Choose the right client to ensure proper project organization</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Guide -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle me-2 text-info"></i>Billing Guide
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="billing-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-billable me-2">Billable</span>
|
||||
<small class="text-muted">Track time and bill client</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-guide-item mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-non-billable me-2">Non-Billable</span>
|
||||
<small class="text-muted">Track time without billing</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billing-guide-item">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="billing-badge billing-rate me-2">Rate Setting</span>
|
||||
<small class="text-muted">Set appropriate hourly rates</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Management -->
|
||||
<div class="card mobile-card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tasks me-2 text-secondary"></i>Project Management
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="management-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-primary fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Time Tracking</small>
|
||||
<small class="text-muted">Log time entries for accurate project tracking</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="management-item mb-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-list text-success fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Task Creation</small>
|
||||
<small class="text-muted">Break down projects into manageable tasks</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="management-item">
|
||||
<div class="d-flex align-items-start">
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-chart-bar text-info fa-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<small class="fw-semibold d-block">Progress Monitoring</small>
|
||||
<small class="text-muted">Track project progress and time allocation</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Billing Badges */
|
||||
.billing-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.billing-billable {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.billing-non-billable {
|
||||
background-color: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.billing-rate {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* Tip Items */
|
||||
.tip-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background-color: #f8fafc;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tip-item:hover {
|
||||
background-color: #f1f5f9;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Billing and Management Guide Items */
|
||||
.billing-guide-item,
|
||||
.management-item {
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.billing-guide-item:hover,
|
||||
.management-item:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.form-control-lg {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.form-select-lg {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: 1rem 1rem 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.tip-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.billing-guide-item:hover,
|
||||
.management-item:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.tip-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const clientSelect = document.getElementById('client_id');
|
||||
const hourlyRateInput = document.getElementById('hourly_rate');
|
||||
const billableCheckbox = document.getElementById('billable');
|
||||
const form = document.getElementById('createProjectForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
|
||||
// Auto-fill hourly rate from client default
|
||||
clientSelect.addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const defaultRate = selectedOption.getAttribute('data-default-rate');
|
||||
@@ -122,6 +384,76 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
hourlyRateInput.value = defaultRate;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle hourly rate field based on billable checkbox
|
||||
billableCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
hourlyRateInput.removeAttribute('disabled');
|
||||
hourlyRateInput.classList.remove('text-muted');
|
||||
} else {
|
||||
hourlyRateInput.setAttribute('disabled', 'disabled');
|
||||
hourlyRateInput.classList.add('text-muted');
|
||||
hourlyRateInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Form validation and enhancement
|
||||
form.addEventListener('submit', function(e) {
|
||||
const name = nameInput.value.trim();
|
||||
const clientId = clientSelect.value;
|
||||
|
||||
if (!name || !clientId) {
|
||||
e.preventDefault();
|
||||
if (!name) {
|
||||
nameInput.focus();
|
||||
nameInput.classList.add('is-invalid');
|
||||
}
|
||||
if (!clientId) {
|
||||
clientSelect.focus();
|
||||
clientSelect.classList.add('is-invalid');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Creating...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Re-enable after a delay (in case of validation errors)
|
||||
setTimeout(() => {
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Real-time validation feedback
|
||||
nameInput.addEventListener('input', function() {
|
||||
if (this.value.trim()) {
|
||||
this.classList.remove('is-invalid');
|
||||
this.classList.add('is-valid');
|
||||
} else {
|
||||
this.classList.remove('is-valid');
|
||||
}
|
||||
});
|
||||
|
||||
clientSelect.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
this.classList.remove('is-invalid');
|
||||
this.classList.add('is-valid');
|
||||
} else {
|
||||
this.classList.remove('is-valid');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize form state
|
||||
if (billableCheckbox.checked) {
|
||||
hourlyRateInput.removeAttribute('disabled');
|
||||
} else {
|
||||
hourlyRateInput.setAttribute('disabled', 'disabled');
|
||||
hourlyRateInput.classList.add('text-muted');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,50 +2,361 @@
|
||||
|
||||
{% block title %}Edit Time Entry - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if current_user.is_admin %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const projectSelect = document.getElementById('project_id');
|
||||
const taskSelect = document.getElementById('task_id');
|
||||
const form = document.querySelector('form');
|
||||
|
||||
if (projectSelect && taskSelect) {
|
||||
projectSelect.addEventListener('change', function() {
|
||||
const projectId = this.value;
|
||||
|
||||
// Clear current tasks
|
||||
taskSelect.innerHTML = '<option value="">No Task</option>';
|
||||
|
||||
if (projectId) {
|
||||
// Fetch tasks for the selected project
|
||||
fetch(`/api/projects/${projectId}/tasks`, { credentials: 'same-origin' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.tasks) {
|
||||
data.tasks.forEach(task => {
|
||||
const option = document.createElement('option');
|
||||
option.value = task.id;
|
||||
option.textContent = task.name;
|
||||
taskSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching tasks:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add form submission confirmation for admin users (custom modal)
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
// If we already confirmed, let it proceed
|
||||
if (form.dataset.confirmed === '1') {
|
||||
delete form.dataset.confirmed;
|
||||
return true;
|
||||
}
|
||||
|
||||
const originalProject = '{{ timer.project.name }}';
|
||||
const selectedProject = projectSelect.options[projectSelect.selectedIndex].text;
|
||||
const originalStart = '{{ timer.start_time.strftime("%Y-%m-%d %H:%M") }}';
|
||||
const originalEnd = '{{ timer.end_time.strftime("%Y-%m-%d %H:%M") if timer.end_time else "Running" }}';
|
||||
const originalNotes = {{ (timer.notes or '')|tojson }};
|
||||
const originalTags = {{ (timer.tags or '')|tojson }};
|
||||
const originalBillable = {{ 'true' if timer.billable else 'false' }};
|
||||
|
||||
const startDate = document.getElementById('start_date').value;
|
||||
const startTime = document.getElementById('start_time').value;
|
||||
const endDate = document.getElementById('end_date').value;
|
||||
const endTime = document.getElementById('end_time').value;
|
||||
const notesVal = document.getElementById('notes').value || '';
|
||||
const tagsVal = document.getElementById('tags').value || '';
|
||||
const billableVal = document.getElementById('billable').checked;
|
||||
|
||||
const newStart = startDate && startTime ? `${startDate} ${startTime}` : originalStart;
|
||||
const newEnd = endDate && endTime ? `${endDate} ${endTime}` : originalEnd;
|
||||
|
||||
const changes = [];
|
||||
if (originalProject !== selectedProject) changes.push({ label: 'Project', from: originalProject, to: selectedProject });
|
||||
if (originalStart !== newStart) changes.push({ label: 'Start', from: originalStart, to: newStart });
|
||||
if (originalEnd !== newEnd) changes.push({ label: 'End', from: originalEnd, to: newEnd });
|
||||
if (originalNotes !== notesVal) changes.push({ label: 'Notes', from: originalNotes || '—', to: notesVal || '—' });
|
||||
if (originalTags !== tagsVal) changes.push({ label: 'Tags', from: originalTags || '—', to: tagsVal || '—' });
|
||||
if ((originalBillable ? true : false) !== billableVal) changes.push({ label: 'Billable', from: originalBillable ? 'Yes' : 'No', to: billableVal ? 'Yes' : 'No' });
|
||||
|
||||
if (changes.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
const modalEl = document.getElementById('confirmChangesModal');
|
||||
const summaryEl = document.getElementById('confirmChangesSummary');
|
||||
const confirmBtn = document.getElementById('confirmChangesConfirmBtn');
|
||||
|
||||
summaryEl.innerHTML = changes.map(ch => `
|
||||
<div class="mb-2">
|
||||
<div class="small text-muted">${ch.label}</div>
|
||||
<div><span class="text-danger">${ch.from}</span> <i class="fas fa-arrow-right mx-2"></i> <span class="text-success">${ch.to}</span></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
confirmBtn.onclick = function() {
|
||||
const inst = bootstrap.Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
form.dataset.confirmed = '1';
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure modal is attached to body to avoid stacking/pointer issues
|
||||
try {
|
||||
if (modalEl.parentElement !== document.body) {
|
||||
document.body.appendChild(modalEl);
|
||||
}
|
||||
} catch (e) {}
|
||||
const bsModal = new bootstrap.Modal(modalEl, { backdrop: 'static', keyboard: false });
|
||||
bsModal.show();
|
||||
// Focus confirm button when modal is shown
|
||||
modalEl.addEventListener('shown.bs.modal', function onShown() {
|
||||
confirmBtn.focus();
|
||||
modalEl.removeEventListener('shown.bs.modal', onShown);
|
||||
});
|
||||
// Handle Enter/Escape keys inside modal
|
||||
modalEl.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
confirmBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure admin save button programmatically submits the form (avoids any other JS interference)
|
||||
const adminSaveBtn = document.getElementById('adminEditSaveBtn');
|
||||
if (adminSaveBtn && form) {
|
||||
adminSaveBtn.addEventListener('click', function(ev) {
|
||||
ev.preventDefault();
|
||||
if (typeof form.checkValidity === 'function' && !form.checkValidity()) {
|
||||
if (typeof form.reportValidity === 'function') form.reportValidity();
|
||||
return;
|
||||
}
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Live update duration when date/time fields change (admin form)
|
||||
const startDate = document.getElementById('start_date');
|
||||
const startTime = document.getElementById('start_time');
|
||||
const endDate = document.getElementById('end_date');
|
||||
const endTime = document.getElementById('end_time');
|
||||
const durationLabel = document.getElementById('adminEditDuration');
|
||||
function updateDuration() {
|
||||
if (!startDate || !startTime || !endDate || !endTime || !durationLabel) return;
|
||||
const sd = startDate.value;
|
||||
const st = startTime.value;
|
||||
const ed = endDate.value;
|
||||
const et = endTime.value;
|
||||
if (!sd || !st || !ed || !et) {
|
||||
durationLabel.textContent = '--:--:--';
|
||||
return;
|
||||
}
|
||||
const s = new Date(`${sd}T${st}`);
|
||||
const e = new Date(`${ed}T${et}`);
|
||||
const diff = Math.max(0, Math.floor((e - s) / 1000));
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const srem = diff % 60;
|
||||
durationLabel.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${srem.toString().padStart(2,'0')}`;
|
||||
}
|
||||
if (startDate && startTime && endDate && endTime) {
|
||||
startDate.addEventListener('change', updateDuration);
|
||||
startTime.addEventListener('change', updateDuration);
|
||||
endDate.addEventListener('change', updateDuration);
|
||||
endTime.addEventListener('change', updateDuration);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="fas fa-edit me-2 text-primary"></i>
|
||||
<h5 class="mb-0">Edit Time Entry</h5>
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-edit me-2 text-primary"></i>
|
||||
<h5 class="mb-0">Edit Time Entry</h5>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="fas fa-shield-alt me-1"></i>Admin Mode
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Project:</strong> {{ timer.project.name }}
|
||||
{% if current_user.is_admin %}
|
||||
<!-- Admin view with editable fields -->
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Admin Mode:</strong> You can edit all fields of this time entry, including project, task, start/end times, and source.
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="project_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-project-diagram me-1"></i>Project
|
||||
</label>
|
||||
<select class="form-select" id="project_id" name="project_id" required>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if project.id == timer.project_id %}selected{% endif %}>
|
||||
{{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Select the project this time entry belongs to</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="task_id" class="form-label fw-semibold">
|
||||
<i class="fas fa-tasks me-1"></i>Task (Optional)
|
||||
</label>
|
||||
<select class="form-select" id="task_id" name="task_id">
|
||||
<option value="">No Task</option>
|
||||
{% for task in tasks %}
|
||||
<option value="{{ task.id }}" {% if task.id == timer.task_id %}selected{% endif %}>
|
||||
{{ task.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Select a specific task within the project</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Confirm Changes Modal (Admin) -->
|
||||
<div class="modal fade" id="confirmChangesModal" tabindex="-1" role="dialog" aria-modal="true" data-bs-backdrop="static" data-bs-keyboard="false" style="z-index:1085;">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-exclamation-triangle text-warning me-2"></i>Confirm Changes</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-3">You are about to apply the following changes:</p>
|
||||
<div id="confirmChangesSummary"></div>
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
These updates will modify this time entry permanently.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmChangesConfirmBtn">
|
||||
<i class="fas fa-check me-1"></i>Confirm & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="start_date" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-1"></i>Start Date
|
||||
</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||||
value="{{ timer.start_time.strftime('%Y-%m-%d') }}" required>
|
||||
<div class="form-text">When the work started</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="start_time" class="form-label fw-semibold">
|
||||
<i class="fas fa-clock me-1"></i>Start Time
|
||||
</label>
|
||||
<input type="time" class="form-control" id="start_time" name="start_time"
|
||||
value="{{ timer.start_time.strftime('%H:%M') }}" required>
|
||||
<div class="form-text">Time the work started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="end_date" class="form-label fw-semibold">
|
||||
<i class="fas fa-stop-circle me-1"></i>End Date
|
||||
</label>
|
||||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||||
value="{{ timer.end_time.strftime('%Y-%m-%d') if timer.end_time else '' }}">
|
||||
<div class="form-text">When the work ended (leave empty if still running)</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="end_time" class="form-label fw-semibold">
|
||||
<i class="fas fa-stop-circle me-1"></i>End Time
|
||||
</label>
|
||||
<input type="time" class="form-control" id="end_time" name="end_time"
|
||||
value="{{ timer.end_time.strftime('%H:%M') if timer.end_time else '' }}">
|
||||
<div class="form-text">Time the work ended</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="source" class="form-label fw-semibold">
|
||||
<i class="fas fa-tag me-1"></i>Source
|
||||
</label>
|
||||
<select class="form-select" id="source" name="source">
|
||||
<option value="manual" {% if timer.source == 'manual' %}selected{% endif %}>Manual</option>
|
||||
<option value="auto" {% if timer.source == 'auto' %}selected{% endif %}>Automatic</option>
|
||||
</select>
|
||||
<div class="form-text">How this entry was created</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-1"></i>Billable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Duration:</strong> <span id="adminEditDuration">{{ timer.duration_formatted }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Regular user view (read-only) -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Project:</strong> {{ timer.project.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>End:</strong>
|
||||
{% if timer.end_time %}
|
||||
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-primary">Duration: {{ timer.duration_formatted }}</span>
|
||||
{% if timer.source == 'manual' %}
|
||||
<span class="badge bg-secondary">Manual</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Automatic</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-control-plaintext">
|
||||
<strong>End:</strong>
|
||||
{% if timer.end_time %}
|
||||
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-warning">Running</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="badge bg-primary">Duration: {{ timer.duration_formatted }}</span>
|
||||
{% if timer.source == 'manual' %}
|
||||
<span class="badge bg-secondary">Manual</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Automatic</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
|
||||
{% if current_user.is_admin %}
|
||||
<!-- Admin form fields -->
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label fw-semibold">
|
||||
<i class="fas fa-sticky-note me-1"></i>Notes
|
||||
@@ -63,25 +374,57 @@
|
||||
<div class="form-text">Separate tags with commas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-1"></i>Billable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" id="adminEditSaveBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
{% else %}
|
||||
<!-- Regular user form -->
|
||||
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label fw-semibold">
|
||||
<i class="fas fa-sticky-note me-1"></i>Notes
|
||||
</label>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="Describe what you worked on">{{ timer.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-4">
|
||||
<label for="tags" class="form-label fw-semibold">
|
||||
<i class="fas fa-tags me-1"></i>Tags
|
||||
</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags" placeholder="tag1, tag2" value="{{ timer.tags or '' }}">
|
||||
<div class="form-text">Separate tags with commas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="billable">
|
||||
<i class="fas fa-dollar-sign me-1"></i>Billable
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editTimerForm">
|
||||
<form id="editTimerForm" novalidate>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editEntryId" name="entry_id">
|
||||
<div class="row">
|
||||
@@ -241,7 +241,7 @@
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" id="editSaveBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
@@ -281,19 +281,21 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let activeTimer = null;
|
||||
let timerInterval = null;
|
||||
|
||||
// Load projects for dropdowns
|
||||
function loadProjects() {
|
||||
fetch('/api/projects')
|
||||
const promise = fetch('/api/projects', { credentials: 'same-origin' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const projectSelect = document.getElementById('projectSelect');
|
||||
const editProjectSelect = document.getElementById('editProjectSelect');
|
||||
|
||||
if (!projectSelect || !editProjectSelect) return;
|
||||
|
||||
// Clear existing options
|
||||
projectSelect.innerHTML = '<option value="">Select a project...</option>';
|
||||
editProjectSelect.innerHTML = '<option value="">Select a project...</option>';
|
||||
@@ -307,16 +309,19 @@ function loadProjects() {
|
||||
editProjectSelect.add(editOption);
|
||||
}
|
||||
});
|
||||
return data.projects;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading projects:', error);
|
||||
showToast('Error loading projects', 'error');
|
||||
});
|
||||
window.projectsLoadedPromise = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Check timer status
|
||||
function checkTimerStatus() {
|
||||
fetch('/api/timer/status')
|
||||
fetch('/api/timer/status', { credentials: 'same-origin' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.active && data.timer) {
|
||||
@@ -378,7 +383,7 @@ function startTimerDisplay() {
|
||||
|
||||
// Load recent entries
|
||||
function loadRecentEntries() {
|
||||
fetch('/api/entries?limit=10')
|
||||
fetch('/api/entries?limit=10', { credentials: 'same-origin' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const container = document.getElementById('recentEntriesList');
|
||||
@@ -460,6 +465,7 @@ document.getElementById('startTimerForm').addEventListener('submit', function(e)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
@@ -490,7 +496,8 @@ document.getElementById('stopTimerBtn').addEventListener('click', function() {
|
||||
this.disabled = true;
|
||||
|
||||
fetch('/api/timer/stop', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@@ -514,43 +521,54 @@ document.getElementById('stopTimerBtn').addEventListener('click', function() {
|
||||
|
||||
// Edit entry
|
||||
function editEntry(entryId) {
|
||||
fetch(`/api/entry/${entryId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('editEntryId').value = entryId;
|
||||
document.getElementById('editProjectSelect').value = data.project_id;
|
||||
document.getElementById('editStartTime').value = data.start_time.slice(0, 16);
|
||||
document.getElementById('editEndTime').value = data.end_time ? data.end_time.slice(0, 16) : '';
|
||||
const ensureProjects = window.projectsLoadedPromise || loadProjects();
|
||||
Promise.resolve(ensureProjects).then(() => {
|
||||
return fetch(`/api/entry/${entryId}`, { credentials: 'same-origin' })
|
||||
.then(response => response.json());
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('editEntryId').value = entryId;
|
||||
const editProjectSelect = document.getElementById('editProjectSelect');
|
||||
// Ensure the project option exists; if not, append it
|
||||
if (!Array.from(editProjectSelect.options).some(o => o.value == data.project_id)) {
|
||||
const opt = new Option(data.project_name || `Project ${data.project_id}`, data.project_id);
|
||||
editProjectSelect.add(opt);
|
||||
}
|
||||
editProjectSelect.value = data.project_id;
|
||||
// Convert ISO to local datetime-local value
|
||||
const startIso = data.start_time;
|
||||
const endIso = data.end_time;
|
||||
const toLocalInput = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
document.getElementById('editStartTime').value = toLocalInput(startIso);
|
||||
document.getElementById('editEndTime').value = toLocalInput(endIso);
|
||||
document.getElementById('editNotes').value = data.notes || '';
|
||||
document.getElementById('editTags').value = data.tags || '';
|
||||
document.getElementById('editBillable').checked = data.billable;
|
||||
|
||||
// Calculate and display duration
|
||||
if (data.end_time) {
|
||||
const start = new Date(data.start_time);
|
||||
const end = new Date(data.end_time);
|
||||
const duration = Math.floor((end - start) / 1000);
|
||||
const hours = Math.floor(duration / 3600);
|
||||
const minutes = Math.floor((duration % 3600) / 60);
|
||||
const seconds = duration % 60;
|
||||
document.getElementById('editDurationDisplay').textContent =
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
updateEditDurationDisplay();
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editTimerModal')).show();
|
||||
})
|
||||
.catch(error => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading entry:', error);
|
||||
showToast('Error loading entry', 'error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Edit timer form
|
||||
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Debug: signal that edit form handler is attached
|
||||
console.debug('Edit form submit handler attached');
|
||||
|
||||
function performEditSave() {
|
||||
const form = document.getElementById('editTimerForm');
|
||||
if (!form) return;
|
||||
const entryId = document.getElementById('editEntryId').value;
|
||||
const formData = new FormData(this);
|
||||
const formData = new FormData(form);
|
||||
const data = {
|
||||
project_id: formData.get('project_id'),
|
||||
start_time: formData.get('start_time'),
|
||||
@@ -559,39 +577,87 @@ document.getElementById('editTimerForm').addEventListener('submit', function(e)
|
||||
tags: formData.get('tags'),
|
||||
billable: formData.get('billable') === 'on'
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Saving...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
console.debug('Submitting data', data);
|
||||
|
||||
// Validate
|
||||
const startVal = data.start_time ? new Date(data.start_time) : null;
|
||||
const endVal = data.end_time ? new Date(data.end_time) : null;
|
||||
if (startVal && endVal && endVal <= startVal) {
|
||||
showToast('End time must be after start time', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Saving...';
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
|
||||
fetch(`/api/entry/${entryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
.then(r => r.json())
|
||||
.then(payload => {
|
||||
console.debug('PUT response', payload);
|
||||
if (payload.success) {
|
||||
showToast('Entry updated successfully', 'success');
|
||||
bootstrap.Modal.getInstance(document.getElementById('editTimerModal')).hide();
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editTimerModal'));
|
||||
if (modal) modal.hide();
|
||||
loadRecentEntries();
|
||||
} else {
|
||||
showToast(data.message || 'Error updating entry', 'error');
|
||||
showToast(payload.message || 'Error updating entry', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating entry:', error);
|
||||
.catch(err => {
|
||||
console.error('Error updating entry:', err);
|
||||
showToast('Error updating entry', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
submitBtn.disabled = false;
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Edit timer form
|
||||
document.getElementById('editTimerForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
console.debug('Edit form submit event fired');
|
||||
performEditSave();
|
||||
});
|
||||
|
||||
// Ensure click on Save triggers submit handler even if native validation blocks it
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('#editSaveBtn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
performEditSave();
|
||||
});
|
||||
|
||||
// Live update duration when times change in edit modal
|
||||
function updateEditDurationDisplay() {
|
||||
const start = document.getElementById('editStartTime').value;
|
||||
const end = document.getElementById('editEndTime').value;
|
||||
const display = document.getElementById('editDurationDisplay');
|
||||
if (!start || !end) {
|
||||
display.textContent = '--:--:--';
|
||||
return;
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
const diff = Math.max(0, Math.floor((endDate - startDate) / 1000));
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
display.textContent = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
|
||||
}
|
||||
document.getElementById('editStartTime').addEventListener('change', updateEditDurationDisplay);
|
||||
document.getElementById('editEndTime').addEventListener('change', updateEditDurationDisplay);
|
||||
|
||||
// Delete timer
|
||||
document.getElementById('deleteTimerBtn').addEventListener('click', function() {
|
||||
const entryId = document.getElementById('editEntryId').value;
|
||||
@@ -616,7 +682,7 @@ function deleteEntry(entryId) {
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProjects();
|
||||
window.projectsLoadedPromise = loadProjects();
|
||||
checkTimerStatus();
|
||||
loadRecentEntries();
|
||||
|
||||
@@ -639,7 +705,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
fetch(`/api/entry/${entryId}`, {
|
||||
method: 'DELETE'
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
||||
Reference in New Issue
Block a user