diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..60be610 --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/Dockerfile b/Dockerfile index 07d1d9d..2921229 100644 --- a/Dockerfile +++ b/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"] diff --git a/README.md b/README.md index cd5bf50..311c246 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ A comprehensive web-based time tracking application built with Flask, featuring
Reports Visual Analytics - Task Management Admin Panel
@@ -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 diff --git a/app/models/invoice.py b/app/models/invoice.py index 5dae137..e81c992 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -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, diff --git a/app/routes/api.py b/app/routes/api.py index 2d4f523..856c164 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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//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/', 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/', methods=['DELETE']) @login_required diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 20bd437..f492359 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -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//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 diff --git a/app/routes/timer.py b/app/routes/timer.py index 0690590..e228f9b 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -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/', methods=['POST']) @login_required diff --git a/app/static/mobile.js b/app/static/mobile.js index dba304d..9904955 100644 --- a/app/static/mobile.js +++ b/app/static/mobile.js @@ -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 = '
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 } } diff --git a/app/utils/pdf_generator.py b/app/utils/pdf_generator.py index 505c8bf..26ba22d 100644 --- a/app/utils/pdf_generator.py +++ b/app/utils/pdf_generator.py @@ -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)} {item.quantity:.2f} - {item.unit_price:.2f} {self.settings.currency} - {item.total_amount:.2f} {self.settings.currency} + {self._format_currency(item.unit_price)} + {self._format_currency(item.total_amount)} """ rows.append(row) @@ -254,7 +261,7 @@ class InvoicePDFGenerator: rows.append(f""" Subtotal: - {self.invoice.subtotal:.2f} {self.settings.currency} + {self._format_currency(self.invoice.subtotal)} """) @@ -263,7 +270,7 @@ class InvoicePDFGenerator: rows.append(f""" Tax ({self.invoice.tax_rate:.2f}%): - {self.invoice.tax_amount:.2f} {self.settings.currency} + {self._format_currency(self.invoice.tax_amount)} """) @@ -271,7 +278,7 @@ class InvoicePDFGenerator: rows.append(f""" Total Amount: - {self.invoice.total_amount:.2f} {self.settings.currency} + {self._format_currency(self.invoice.total_amount)} """) @@ -301,6 +308,13 @@ class InvoicePDFGenerator: return f'
{"".join(html_parts)}
' 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: diff --git a/app/utils/pdf_generator_fallback.py b/app/utils/pdf_generator_fallback.py index 566d1aa..549da28 100644 --- a/app/utils/pdf_generator_fallback.py +++ b/app/utils/pdf_generator_fallback.py @@ -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""" diff --git a/assets/screenshots/Task_Management.png b/assets/screenshots/Task_Management.png deleted file mode 100644 index 6324984..0000000 --- a/assets/screenshots/Task_Management.png +++ /dev/null @@ -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. diff --git a/docker/entrypoint.py b/docker/entrypoint.py new file mode 100644 index 0000000..1aaaa87 --- /dev/null +++ b/docker/entrypoint.py @@ -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() diff --git a/docker/entrypoint_simple.sh b/docker/entrypoint_simple.sh new file mode 100644 index 0000000..40bda4b --- /dev/null +++ b/docker/entrypoint_simple.sh @@ -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 "$@" diff --git a/docker/init-database-enhanced.py b/docker/init-database-enhanced.py index 2ed98ea..2a8b378 100644 --- a/docker/init-database-enhanced.py +++ b/docker/init-database-enhanced.py @@ -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") diff --git a/docker/init-database-sql.py b/docker/init-database-sql.py index e111468..3562f3a 100644 --- a/docker/init-database-sql.py +++ b/docker/init-database-sql.py @@ -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: diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index 1bdd091..5246426 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -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 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 311c672..109890e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -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 ``` diff --git a/docs/DOCKER_PUBLIC_SETUP.md b/docs/DOCKER_PUBLIC_SETUP.md index b93ee35..e4136ca 100644 --- a/docs/DOCKER_PUBLIC_SETUP.md +++ b/docs/DOCKER_PUBLIC_SETUP.md @@ -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} diff --git a/docs/DOCKER_STARTUP_TROUBLESHOOTING.md b/docs/DOCKER_STARTUP_TROUBLESHOOTING.md index 9547b4a..b82547e 100644 --- a/docs/DOCKER_STARTUP_TROUBLESHOOTING.md +++ b/docs/DOCKER_STARTUP_TROUBLESHOOTING.md @@ -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 diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index fe564b6..6f55192 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 7a9c791..7734510 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/templates/invoices/view.html b/templates/invoices/view.html index 31ebb9e..8b0cc92 100644 --- a/templates/invoices/view.html +++ b/templates/invoices/view.html @@ -168,13 +168,54 @@ + {% set tax_present = invoice.tax_rate > 0 %} + +
+
+
+
+
+
+
Subtotal
+
{{ "%.2f"|format(invoice.subtotal) }} {{ currency }}
+
+
+
+
+ {% if tax_present %} +
+
+
+
+
+
Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)
+
{{ "%.2f"|format(invoice.tax_amount) }} {{ currency }}
+
+
+
+
+ {% endif %} +
+
+
+
+
+
Total Amount
+
{{ "%.2f"|format(invoice.total_amount) }} {{ currency }}
+
+
+
+
+
+
@@ -54,9 +67,9 @@
Navigation
  • Dashboard: Overview of your current timer and recent activity
  • -
  • Timer: Start, stop, and manage time tracking
  • -
  • Projects: View and manage projects (admin only)
  • -
  • Reports: Generate time reports and export data
  • +
  • Timer: Start, stop, and manage time tracking (Log Time)
  • +
  • Projects: View and manage projects{% if current_user.is_admin %} (Open Projects){% endif %}
  • +
  • Reports: Generate time reports and export data (Open Reports)
  • {% if current_user.is_admin %}
  • Admin: Manage users and system settings
  • {% endif %} @@ -153,7 +166,7 @@
Exporting Data
-

Use the CSV export feature to download time entries for use in external tools like Excel or accounting software.

+

Use the CSV export feature to download time entries for external tools. Find it on the Reports page.

diff --git a/templates/projects/create.html b/templates/projects/create.html index 51e4765..1486e22 100644 --- a/templates/projects/create.html +++ b/templates/projects/create.html @@ -1,53 +1,57 @@ {% extends "base.html" %} -{% block title %}New Project - {{ app_name }}{% endblock %} +{% block title %}Create Project - {{ app_name }}{% endblock %} {% block content %} -
-
+
+ +
-
-
- -

- New Project -

-
-
- - Back to Projects - +
+
+
+
+ +
+
+

Create New Project

+

Set up a new project to organize your work and track time effectively

+
+
+
-
+
-
- Project Information -
+
+ Project Information +
-
-
+ + +
- - + + + Choose a clear, descriptive name that explains the project scope
- - {% for client in clients %}
-
- - + +
+ + + Optional: Add context, objectives, or specific requirements for the project
-
+ +
- + + Enable billing for this project
- - -
Leave empty for non-billable projects
+ + + Leave empty for non-billable projects
-
- - + +
+ + + Optional: Add a reference number or identifier for billing purposes
-
- - Cancel - - + + Cancel +
+ + +
+ +
+
+
+ Project Creation Tips +
+
+
+
+
+
+ +
+
+ Clear Naming + Use descriptive names that clearly indicate the project's purpose +
+
+
+ +
+
+
+ +
+
+ Billing Setup + Set appropriate hourly rates based on project complexity and client budget +
+
+
+ +
+
+
+ +
+
+ Detailed Description + Include project objectives, deliverables, and key requirements +
+
+
+ +
+
+
+ +
+
+ Client Selection + Choose the right client to ensure proper project organization +
+
+
+
+
+ + +
+
+
+ Billing Guide +
+
+
+
+
+ Billable + Track time and bill client +
+
+ +
+
+ Non-Billable + Track time without billing +
+
+ +
+
+ Rate Setting + Set appropriate hourly rates +
+
+
+
+ + +
+
+
+ Project Management +
+
+
+
+
+
+ +
+
+ Time Tracking + Log time entries for accurate project tracking +
+
+
+ +
+
+
+ +
+
+ Task Creation + Break down projects into manageable tasks +
+
+
+ +
+
+
+ +
+
+ Progress Monitoring + Track project progress and time allocation +
+
+
+
+
+
+ + {% endblock %} diff --git a/templates/timer/edit_timer.html b/templates/timer/edit_timer.html index 0f60219..3fa018a 100644 --- a/templates/timer/edit_timer.html +++ b/templates/timer/edit_timer.html @@ -2,50 +2,361 @@ {% block title %}Edit Time Entry - {{ app_name }}{% endblock %} +{% block extra_js %} +{% if current_user.is_admin %} + +{% endif %} +{% endblock %} + {% block content %}
-
- -
Edit Time Entry
+
+
+ +
Edit Time Entry
+
+ {% if current_user.is_admin %} + + Admin Mode + + {% endif %}
-
-
-
- Project: {{ timer.project.name }} + {% if current_user.is_admin %} + +
+ + Admin Mode: You can edit all fields of this time entry, including project, task, start/end times, and source. +
+
+
+
+ + +
Select the project this time entry belongs to
+
+
+ + +
Select a specific task within the project
+
+
+ + + +
+
+ + +
When the work started
+
+
+ + +
Time the work started
+
+
+ +
+
+ + +
When the work ended (leave empty if still running)
+
+
+ + +
Time the work ended
+
+
+ +
+
+ + +
How this entry was created
+
+
+
+ + +
+
+
+
+ Duration: {{ timer.duration_formatted }} +
+
+
+ {% else %} + +
+
+
+ Project: {{ timer.project.name }} +
+
+
+
+ Start: {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }} +
+
+
+
+ End: + {% if timer.end_time %} + {{ timer.end_time.strftime('%Y-%m-%d %H:%M') }} + {% else %} + Running + {% endif %} +
-
-
- Start: {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }} -
+
+ Duration: {{ timer.duration_formatted }} + {% if timer.source == 'manual' %} + Manual + {% else %} + Automatic + {% endif %}
-
-
- End: - {% if timer.end_time %} - {{ timer.end_time.strftime('%Y-%m-%d %H:%M') }} - {% else %} - Running - {% endif %} -
-
-
-
- Duration: {{ timer.duration_formatted }} - {% if timer.source == 'manual' %} - Manual - {% else %} - Automatic - {% endif %} -
+ {% endif %}
- + {% if current_user.is_admin %} +
-
-
- - -
-
Back -
- + + {% else %} + +
+
+ + +
+ +
+
+
+ + +
Separate tags with commas
+
+
+
+
+ + +
+
+
+ +
+ + Back + + +
+
+ {% endif %}
diff --git a/templates/timer/timer.html b/templates/timer/timer.html index 00877b3..e345672 100644 --- a/templates/timer/timer.html +++ b/templates/timer/timer.html @@ -164,7 +164,7 @@
-
+ {% endblock %} -{% block scripts %} +{% block extra_js %}