diff --git a/app/integrations/quickbooks.py b/app/integrations/quickbooks.py index 9ae001d..bf85bdb 100644 --- a/app/integrations/quickbooks.py +++ b/app/integrations/quickbooks.py @@ -293,7 +293,7 @@ class QuickBooksConnector(BaseConnector): # Sync expenses (create as expenses in QuickBooks) if sync_type == "full" or sync_type == "expenses": try: - expenses = Expense.query.filter(Expense.date >= datetime.utcnow().date() - timedelta(days=90)).all() + expenses = Expense.query.filter(Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90)).all() for expense in expenses: try: diff --git a/app/integrations/xero.py b/app/integrations/xero.py index 3a89180..566e895 100644 --- a/app/integrations/xero.py +++ b/app/integrations/xero.py @@ -243,7 +243,7 @@ class XeroConnector(BaseConnector): # Sync expenses (create as expenses in Xero) if sync_type == "full" or sync_type == "expenses": - expenses = Expense.query.filter(Expense.date >= datetime.utcnow().date() - timedelta(days=90)).all() + expenses = Expense.query.filter(Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90)).all() for expense in expenses: try: diff --git a/app/models/settings.py b/app/models/settings.py index 028b0bb..72f31bd 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -98,6 +98,14 @@ class Settings(db.Model): kiosk_require_reason_for_adjustments = db.Column(db.Boolean, default=False, nullable=False) kiosk_default_movement_type = db.Column(db.String(20), default="adjustment", nullable=False) + # Time entry requirements (admin-enforced when logging time) + time_entry_require_task = db.Column(db.Boolean, default=False, nullable=False) + time_entry_require_description = db.Column(db.Boolean, default=False, nullable=False) + time_entry_description_min_length = db.Column(db.Integer, default=20, nullable=False) + + # Overtime / time tracking: default daily working hours for new users (e.g. 8.0) + default_daily_working_hours = db.Column(db.Float, default=8.0, nullable=False) + # Email configuration settings (stored in database, takes precedence over environment variables) mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config mail_server = db.Column(db.String(255), default="", nullable=True) @@ -451,6 +459,10 @@ class Settings(db.Model): "quickbooks_client_secret_set": bool(getattr(self, "quickbooks_client_secret", "")), "xero_client_id": getattr(self, "xero_client_id", "") or "", "xero_client_secret_set": bool(getattr(self, "xero_client_secret", "")), + "time_entry_require_task": getattr(self, "time_entry_require_task", False), + "time_entry_require_description": getattr(self, "time_entry_require_description", False), + "time_entry_description_min_length": getattr(self, "time_entry_description_min_length", 20), + "default_daily_working_hours": float(getattr(self, "default_daily_working_hours", 8.0) or 8.0), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } @@ -620,6 +632,7 @@ class Settings(db.Model): "IDLE_TIMEOUT_MINUTES": "idle_timeout_minutes", "BACKUP_RETENTION_DAYS": "backup_retention_days", "BACKUP_TIME": "backup_time", + "DEFAULT_DAILY_WORKING_HOURS": "default_daily_working_hours", } for env_var, attr_name in env_mapping.items(): @@ -638,6 +651,11 @@ class Settings(db.Model): setattr(settings_instance, attr_name, int(env_value)) except (ValueError, TypeError): pass # Keep default if conversion fails + elif isinstance(current_value, float): + try: + setattr(settings_instance, attr_name, float(env_value)) + except (ValueError, TypeError): + pass else: # Handle string values setattr(settings_instance, attr_name, env_value) diff --git a/app/models/user.py b/app/models/user.py index 21cce77..501d794 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -49,6 +49,9 @@ class User(UserMixin, db.Model): standard_hours_per_day = db.Column( db.Float, default=8.0, nullable=False ) # Standard working hours per day for overtime calculation + overtime_include_weekends = db.Column( + db.Boolean, default=True, nullable=False + ) # If True, weekend hours count toward regular/overtime; if False, all weekend hours count as overtime # Client portal settings client_portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access @@ -291,6 +294,8 @@ class User(UserMixin, db.Model): "date_format": resolved_date, "time_format": resolved_time, "timezone": resolved_timezone, + "standard_hours_per_day": float(getattr(self, "standard_hours_per_day", 8.0) or 8.0), + "overtime_include_weekends": getattr(self, "overtime_include_weekends", True), } # Avatar helpers diff --git a/app/routes/admin.py b/app/routes/admin.py index 2e02a30..5d9fb8e 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -772,6 +772,12 @@ def create_user(): # Create user with legacy role field for backward compatibility user = User(username=username, role=role_name) + # Apply company default for daily working hours (overtime) + try: + settings = Settings.get_settings() + user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) + except Exception: + pass # Assign the role from the new Role system user.roles.append(role_obj) @@ -1247,6 +1253,23 @@ def settings(): # Kiosk columns don't exist yet (migration not run) pass + # Update time entry requirements (if columns exist) + try: + settings_obj.time_entry_require_task = request.form.get("time_entry_require_task") == "on" + settings_obj.time_entry_require_description = request.form.get("time_entry_require_description") == "on" + min_len = int(request.form.get("time_entry_description_min_length", 20)) + settings_obj.time_entry_description_min_length = max(1, min(500, min_len)) + except AttributeError: + pass + + # Update default daily working hours (overtime) for new users + try: + val = request.form.get("default_daily_working_hours", type=float) + if val is not None and 0.5 <= val <= 24: + settings_obj.default_daily_working_hours = val + except (AttributeError, ValueError, TypeError): + pass + # Update privacy and analytics settings allow_analytics = request.form.get("allow_analytics") == "on" old_analytics_state = settings_obj.allow_analytics diff --git a/app/routes/analytics.py b/app/routes/analytics.py index 22f464c..53f99e6 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -410,6 +410,8 @@ def overtime_analytics(): total_overtime = 0 total_regular = 0 + total_undertime = 0 + total_days_under = 0 for user in users: overtime_info = calculate_period_overtime(user, start_date, end_date) if overtime_info["total_hours"] > 0: # Only include users with tracked time @@ -418,12 +420,16 @@ def overtime_analytics(): "username": user.display_name, "regular_hours": overtime_info["regular_hours"], "overtime_hours": overtime_info["overtime_hours"], + "undertime_hours": overtime_info.get("undertime_hours", 0), + "days_under": overtime_info.get("days_under", 0), "total_hours": overtime_info["total_hours"], "days_with_overtime": overtime_info["days_with_overtime"], } ) total_overtime += overtime_info["overtime_hours"] total_regular += overtime_info["regular_hours"] + total_undertime += overtime_info.get("undertime_hours", 0) + total_days_under += overtime_info.get("days_under", 0) # Get daily breakdown for chart if not current_user.is_admin: @@ -438,6 +444,8 @@ def overtime_analytics(): "summary": { "total_regular_hours": round(total_regular, 2), "total_overtime_hours": round(total_overtime, 2), + "total_undertime_hours": round(total_undertime, 2), + "days_under": total_days_under, "total_hours": round(total_regular + total_overtime, 2), "overtime_percentage": round( ( @@ -453,6 +461,8 @@ def overtime_analytics(): "date": day["date_str"], "regular_hours": day["regular_hours"], "overtime_hours": day["overtime_hours"], + "undertime_hours": day.get("undertime_hours", 0), + "is_undertime": day.get("is_undertime", False), "total_hours": day["total_hours"], } for day in daily_data diff --git a/app/routes/api.py b/app/routes/api.py index 0000e05..55296ce 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -286,9 +286,13 @@ def list_tasks_for_project(): @login_required def api_start_timer(): """Start timer via API""" - data = request.get_json() + from app.models import Settings + from app.utils.time_entry_validation import validate_time_entry_requirements + + data = request.get_json() or {} project_id = data.get("project_id") task_id = data.get("task_id") + notes = (data.get("notes") or "").strip() or None if not project_id: return jsonify({"error": "Project ID is required"}), 400 @@ -305,6 +309,14 @@ def api_start_timer(): if not task: return jsonify({"error": "Invalid task for selected project"}), 400 + # Validate time entry requirements (task, description) + settings = Settings.get_settings() + err = validate_time_entry_requirements( + settings, project_id=project_id, client_id=None, task_id=task_id, notes=notes + ) + if err: + return jsonify({"error": err["message"]}), 400 + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: @@ -318,6 +330,7 @@ def api_start_timer(): project_id=project_id, task_id=task.id if task else None, start_time=local_now(), + notes=notes, source="auto", ) @@ -1428,29 +1441,44 @@ def get_users(): @login_required def get_stats(): """Get user statistics""" + from app.utils.overtime import calculate_period_overtime + # Get date range end_date = datetime.utcnow() start_date = end_date - timedelta(days=30) + today = end_date.date() + week_start = today - timedelta(days=today.weekday()) + user_id = current_user.id if not current_user.is_admin else None # Calculate statistics today_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date(), user_id=current_user.id if not current_user.is_admin else None + start_date=today, user_id=user_id ) week_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date() - timedelta(days=7), user_id=current_user.id if not current_user.is_admin else None + start_date=week_start, user_id=user_id ) month_hours = TimeEntry.get_total_hours_for_period( - start_date=start_date.date(), user_id=current_user.id if not current_user.is_admin else None + start_date=start_date.date(), user_id=user_id ) + # Overtime for today and week + today_overtime = calculate_period_overtime(current_user, today, today) + week_overtime = calculate_period_overtime(current_user, week_start, today) + standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) + return jsonify( { "today_hours": today_hours, "week_hours": week_hours, "month_hours": month_hours, "total_hours": current_user.total_hours, + "standard_hours_per_day": standard_hours, + "today_regular_hours": today_overtime["regular_hours"], + "today_overtime_hours": today_overtime["overtime_hours"], + "week_regular_hours": week_overtime["regular_hours"], + "week_overtime_hours": week_overtime["overtime_hours"], } ) @@ -1752,6 +1780,7 @@ def dashboard_stats(): """Get dashboard statistics for real-time updates""" from app.models import TimeEntry from datetime import datetime, timedelta + from app.utils.overtime import calculate_period_overtime today = datetime.utcnow().date() week_start = today - timedelta(days=today.weekday()) @@ -1763,12 +1792,22 @@ def dashboard_stats(): month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id) + # Overtime for today and week (for dashboard cards) + today_overtime = calculate_period_overtime(current_user, today, today) + week_overtime = calculate_period_overtime(current_user, week_start, today) + standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) + return jsonify( { "success": True, "today_hours": float(today_hours), "week_hours": float(week_hours), "month_hours": float(month_hours), + "standard_hours_per_day": standard_hours, + "today_regular_hours": today_overtime["regular_hours"], + "today_overtime_hours": today_overtime["overtime_hours"], + "week_regular_hours": week_overtime["regular_hours"], + "week_overtime_hours": week_overtime["overtime_hours"], } ) diff --git a/app/routes/auth.py b/app/routes/auth.py index 9f44cc6..4e4f3d7 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -140,6 +140,13 @@ def login(): # Create new user, promote to admin if username is configured as admin role_name = "admin" if username in admin_usernames else "user" user = User(username=username, role=role_name) + # Apply company default for daily working hours (overtime) + try: + from app.models import Settings + settings = Settings.get_settings() + user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) + except Exception: + pass # Assign role from the new Role system from app.models import Role @@ -985,6 +992,13 @@ def oidc_callback(): user.is_active = True user.oidc_issuer = issuer user.oidc_sub = sub + # Apply company default for daily working hours (overtime) + try: + from app.models import Settings + settings = Settings.get_settings() + user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0) + except Exception: + pass # Assign role from the new Role system from app.models import Role diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index 9f46317..ecf2fbc 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -6,7 +6,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db -from app.models import SavedReportView, TimeEntry, Project, Task, User, Client +from app.models import SavedReportView, TimeEntry, Project, Task, User, Client, Expense, Invoice from app.utils.db import safe_commit from app.services.unpaid_hours_service import UnpaidHoursService import json @@ -448,6 +448,126 @@ def generate_report_data(config, user_id=None): "summary": {"total_projects": len(projects)}, } + elif data_source == "expenses": + from sqlalchemy.orm import joinedload + + start_date = start_dt.date() if hasattr(start_dt, "date") else start_dt + end_date = end_dt.date() if hasattr(end_dt, "date") else end_dt + + query = Expense.query.filter( + Expense.expense_date >= start_date, + Expense.expense_date <= end_date, + ) + + if user_id: + user = User.query.get(user_id) + if not user: + return {"success": False, "message": f"User {user_id} not found", "data": []} + if not user.is_admin: + query = query.filter(Expense.user_id == user_id) + + if filters.get("project_id"): + try: + pid = int(filters["project_id"]) if isinstance(filters["project_id"], str) else filters["project_id"] + query = query.filter(Expense.project_id == pid) + except (ValueError, TypeError): + pass + + expenses = query.options( + joinedload(Expense.project), + joinedload(Expense.user), + joinedload(Expense.client), + ).order_by(Expense.expense_date.desc()).all() + + data_list = [ + { + "id": e.id, + "date": e.expense_date.isoformat() if e.expense_date else "", + "title": e.title, + "category": e.category, + "amount": float(e.amount), + "total_amount": float(e.total_amount), + "currency_code": e.currency_code, + "status": e.status, + "project": e.project.name if e.project else "", + "client": e.client.name if e.client else "", + "user": e.user.username if e.user else "", + "billable": e.billable, + "vendor": e.vendor or "", + "notes": e.notes or "", + } + for e in expenses + ] + + return { + "data": data_list, + "summary": { + "total_expenses": len(expenses), + "total_amount": round(sum(float(e.total_amount) for e in expenses), 2), + }, + } + + elif data_source == "invoices": + from sqlalchemy.orm import joinedload + + start_date = start_dt.date() if hasattr(start_dt, "date") else start_dt + end_date = end_dt.date() if hasattr(end_dt, "date") else end_dt + + query = Invoice.query.filter( + Invoice.issue_date >= start_date, + Invoice.issue_date <= end_date, + ) + + if user_id: + user = User.query.get(user_id) + if not user: + return {"success": False, "message": f"User {user_id} not found", "data": []} + if not user.is_admin: + query = query.filter(Invoice.created_by == user_id) + + if filters.get("project_id"): + try: + pid = int(filters["project_id"]) if isinstance(filters["project_id"], str) else filters["project_id"] + query = query.filter(Invoice.project_id == pid) + except (ValueError, TypeError): + pass + + if filters.get("client_id"): + try: + cid = int(filters["client_id"]) if isinstance(filters["client_id"], str) else filters["client_id"] + query = query.filter(Invoice.client_id == cid) + except (ValueError, TypeError): + pass + + invoices = query.options( + joinedload(Invoice.project), + joinedload(Invoice.client), + ).order_by(Invoice.issue_date.desc()).all() + + data_list = [ + { + "id": inv.id, + "invoice_number": inv.invoice_number, + "issue_date": inv.issue_date.isoformat() if inv.issue_date else None, + "due_date": inv.due_date.isoformat() if inv.due_date else None, + "client_name": inv.client_name, + "status": inv.status, + "total_amount": float(inv.total_amount), + "currency_code": inv.currency_code, + "project": inv.project.name if inv.project else "", + "is_paid": inv.is_paid, + } + for inv in invoices + ] + + return { + "data": data_list, + "summary": { + "total_invoices": len(invoices), + "total_amount": round(sum(float(inv.total_amount) for inv in invoices), 2), + }, + } + # Add more data sources as needed return {"data": [], "summary": {}} diff --git a/app/routes/main.py b/app/routes/main.py index ceec07d..19f7de6 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -68,6 +68,14 @@ def dashboard(): week_hours = stats["time_tracking"]["week_hours"] month_hours = stats["time_tracking"]["month_hours"] + # Overtime for dashboard cards (today and week) + from app.utils.overtime import calculate_period_overtime + today_dt = datetime.utcnow().date() + week_start_dt = today_dt - timedelta(days=today_dt.weekday()) + today_overtime = calculate_period_overtime(current_user, today_dt, today_dt) + week_overtime = calculate_period_overtime(current_user, week_start_dt, today_dt) + standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) + # Build Top Projects (last 30 days) - using optimized query with eager loading from sqlalchemy.orm import joinedload @@ -139,6 +147,11 @@ def dashboard(): "today_hours": today_hours, "week_hours": week_hours, "month_hours": month_hours, + "standard_hours_per_day": standard_hours_per_day, + "today_regular_hours": today_overtime["regular_hours"], + "today_overtime_hours": today_overtime["overtime_hours"], + "week_regular_hours": week_overtime["regular_hours"], + "week_overtime_hours": week_overtime["overtime_hours"], "top_projects": top_projects, "current_week_goal": current_week_goal, "templates": templates, diff --git a/app/routes/reports.py b/app/routes/reports.py index c52d8e1..ce68962 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -356,6 +356,8 @@ def user_report(): overtime_data = calculate_period_overtime(data["user_obj"], start_dt.date(), end_dt.date()) data["regular_hours"] = overtime_data["regular_hours"] data["overtime_hours"] = overtime_data["overtime_hours"] + data["undertime_hours"] = overtime_data.get("undertime_hours", 0) + data["days_under"] = overtime_data.get("days_under", 0) data["days_with_overtime"] = overtime_data["days_with_overtime"] summary = { @@ -383,7 +385,7 @@ def user_report(): @login_required @module_enabled("reports") def export_form(): - """Display CSV export form with filter options""" + """Display export form with filter options (CSV or Excel).""" # Get all users (for admin) users = [] if current_user.is_admin: @@ -401,6 +403,11 @@ def export_form(): default_end_date = datetime.utcnow().strftime("%Y-%m-%d") default_start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") + # Format from query (csv or excel) for Quick Actions consistency + export_format = request.args.get("format", "csv").lower() + if export_format not in ("csv", "excel"): + export_format = "csv" + return render_template( "reports/export_form.html", users=users, @@ -410,6 +417,7 @@ def export_form(): single_client=single_client, default_start_date=default_start_date, default_end_date=default_end_date, + export_format=export_format, ) @@ -751,6 +759,229 @@ def task_report(): ) +def _time_entries_report_query(request, require_dates=True): + """Shared query logic for time entries report and its exports. Returns (entries, start_dt, end_dt, start_date, end_date) or (None, None, None, start_date, end_date) on date error.""" + from app.utils.client_lock import enforce_locked_client_id + + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + user_id = request.args.get("user_id", type=int) + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + client_id = enforce_locked_client_id(client_id) + task_id = request.args.get("task_id", type=int) + billed = request.args.get("billed", "all") # 'all', 'yes', 'no' + + if not start_date: + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") + if not end_date: + end_date = datetime.utcnow().strftime("%Y-%m-%d") + + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + if require_dates: + return None, None, None, start_date, end_date + start_dt = datetime.utcnow() - timedelta(days=30) + end_dt = datetime.utcnow() + + can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt, + ) + + if not can_view_all: + query = query.filter(TimeEntry.user_id == current_user.id) + + if user_id: + if can_view_all: + query = query.filter(TimeEntry.user_id == user_id) + elif user_id != current_user.id: + return None, None, None, start_date, end_date + + if project_id: + query = query.filter(TimeEntry.project_id == project_id) + if task_id: + query = query.filter(TimeEntry.task_id == task_id) + if billed == "yes": + query = query.filter(TimeEntry.paid == True) + elif billed == "no": + query = query.filter(TimeEntry.paid == False) + + if client_id: + project_ids_for_client = db.session.query(Project.id).filter(Project.client_id == client_id) + query = query.filter( + or_(TimeEntry.client_id == client_id, TimeEntry.project_id.in_(project_ids_for_client)) + ) + + entries = query.options( + joinedload(TimeEntry.project).joinedload(Project.client_obj), + joinedload(TimeEntry.user), + joinedload(TimeEntry.task), + joinedload(TimeEntry.client), + ).order_by(TimeEntry.start_time.desc()).all() + + return entries, start_dt, end_dt, start_date, end_date + + +@reports_bp.route("/reports/time-entries") +@login_required +@module_enabled("reports") +def time_entries_report(): + """Time Entries report: list all time entries (billed and unbilled) with Date, Start, Stop, Duration, Project, Task, Notes, Billed, Client.""" + from app.utils.client_lock import enforce_locked_client_id + + can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") + user_id_arg = request.args.get("user_id", type=int) + if not can_view_all and user_id_arg and user_id_arg != current_user.id: + flash(_("You do not have permission to view other users' time entries"), "error") + return redirect(url_for("reports.time_entries_report")) + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + users = User.query.filter_by(is_active=True).order_by(User.username).all() + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + tasks = Task.query.order_by(Task.name).all() + + entries, start_dt, end_dt, start_date, end_date = _time_entries_report_query(request, require_dates=True) + if entries is None: + flash(_("Invalid date format"), "error") + return render_template( + "reports/time_entries_report.html", + projects=projects, + users=users, + clients=clients, + tasks=tasks, + entries=[], + summary={"entries_count": 0, "total_hours": 0}, + start_date=start_date, + end_date=end_date, + selected_user=request.args.get("user_id", type=int), + selected_project=request.args.get("project_id", type=int), + selected_client=enforce_locked_client_id(request.args.get("client_id", type=int)), + selected_task=request.args.get("task_id", type=int), + selected_billed=request.args.get("billed", "all"), + ) + + total_hours = sum(e.duration_hours or 0 for e in entries) + summary = {"entries_count": len(entries), "total_hours": round(total_hours, 2)} + + return render_template( + "reports/time_entries_report.html", + projects=projects, + users=users, + clients=clients, + tasks=tasks, + entries=entries, + summary=summary, + start_date=start_date, + end_date=end_date, + selected_user=request.args.get("user_id", type=int), + selected_project=request.args.get("project_id", type=int), + selected_client=enforce_locked_client_id(request.args.get("client_id", type=int)), + selected_task=request.args.get("task_id", type=int), + selected_billed=request.args.get("billed", "all"), + ) + + +@reports_bp.route("/reports/time-entries/export/excel") +@login_required +@module_enabled("reports") +def time_entries_export_excel(): + """Export Time Entries report as Excel (same filters as report, includes Billed and Client).""" + can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") + user_id_arg = request.args.get("user_id", type=int) + if not can_view_all and user_id_arg and user_id_arg != current_user.id: + flash(_("You do not have permission to export other users' time entries"), "error") + return redirect(url_for("reports.time_entries_report")) + + entries, start_dt, end_dt, start_date, end_date = _time_entries_report_query(request, require_dates=True) + if entries is None: + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.time_entries_report")) + + columns = ["date", "start_time", "end_time", "duration_hours", "project", "task", "notes", "billed", "client"] + if can_view_all: + columns.insert(2, "user") # insert user after end_time for multi-user export + output, filename = create_time_entries_excel( + entries, filename_prefix="time_entries_report", columns=columns + ) + log_event( + "export.excel", + user_id=current_user.id, + export_type="time_entries_report", + num_rows=len(entries), + date_range_days=(end_dt - start_dt).days, + ) + track_event( + current_user.id, + "export.excel", + {"export_type": "time_entries_report", "num_rows": len(entries)}, + ) + return send_file( + output, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + as_attachment=True, + download_name=filename, + ) + + +@reports_bp.route("/reports/time-entries/export/csv") +@login_required +@module_enabled("reports") +def time_entries_export_csv(): + """Export Time Entries report as CSV (same filters as report, includes Billed and Client).""" + can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") + user_id_arg = request.args.get("user_id", type=int) + if not can_view_all and user_id_arg and user_id_arg != current_user.id: + flash(_("You do not have permission to export other users' time entries"), "error") + return redirect(url_for("reports.time_entries_report")) + + entries, start_dt, end_dt, start_date, end_date = _time_entries_report_query(request, require_dates=True) + if entries is None: + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.time_entries_report")) + + settings = Settings.get_settings() + delimiter = settings.export_delimiter + output = io.StringIO() + writer = csv.writer(output, delimiter=delimiter) + headers = [_("Date"), _("Start"), _("End"), _("Duration (hours)"), _("Project"), _("Task"), _("Notes"), _("Billed"), _("Client")] + if can_view_all: + headers.insert(2, _("User")) # after End + writer.writerow(headers) + for entry in entries: + client_name = ( + (entry.client.name if entry.client else "") + or (entry.project.client if entry.project else "") + or "" + ) + row = [ + entry.start_time.date().isoformat() if entry.start_time else "", + entry.start_time.isoformat() if entry.start_time else "", + entry.end_time.isoformat() if entry.end_time else "", + entry.duration_hours if entry.end_time else "", + entry.project.name if entry.project else "", + entry.task.name if entry.task else "", + entry.notes or "", + _("Yes") if entry.paid else _("No"), + client_name, + ] + if can_view_all: + row.insert(2, entry.user.display_name if entry.user else "Unknown") + writer.writerow(row) + output.seek(0) + filename = f"time_entries_report_{start_date}_to_{end_date}.csv" + return send_file( + io.BytesIO(output.getvalue().encode("utf-8")), + mimetype="text/csv", + as_attachment=True, + download_name=filename, + ) + + @reports_bp.route("/reports/export/excel") @login_required @module_enabled("reports") @@ -984,10 +1215,14 @@ def export_user_excel(): overtime_data = calculate_period_overtime(data["user_obj"], start_dt.date(), end_dt.date()) data["regular_hours"] = overtime_data["regular_hours"] data["overtime_hours"] = overtime_data["overtime_hours"] + data["undertime_hours"] = overtime_data.get("undertime_hours", 0) + data["days_under"] = overtime_data.get("days_under", 0) data["days_with_overtime"] = overtime_data["days_with_overtime"] else: data["regular_hours"] = data["hours"] data["overtime_hours"] = 0 + data["undertime_hours"] = 0 + data["days_under"] = 0 data["days_with_overtime"] = 0 # Create Excel file @@ -1007,14 +1242,14 @@ def export_user_excel(): ) # Title - ws.merge_cells("A1:F1") + ws.merge_cells("A1:H1") title_cell = ws["A1"] title_cell.value = f"User Report: {start_date} to {end_date}" title_cell.font = Font(bold=True, size=14) title_cell.alignment = Alignment(horizontal="center") # Headers - headers = ["User", "Total Hours", "Regular Hours", "Overtime Hours", "Billable Hours", "Days with Overtime"] + headers = ["User", "Total Hours", "Regular Hours", "Overtime Hours", "Undertime Hours", "Billable Hours", "Days with Overtime", "Days Under"] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=3, column=col_num) cell.value = header @@ -1030,8 +1265,10 @@ def export_user_excel(): ws.cell(row=row_num, column=2).value = round(data["hours"], 2) ws.cell(row=row_num, column=3).value = round(data.get("regular_hours", data["hours"]), 2) ws.cell(row=row_num, column=4).value = round(data.get("overtime_hours", 0), 2) - ws.cell(row=row_num, column=5).value = round(data["billable_hours"], 2) - ws.cell(row=row_num, column=6).value = data.get("days_with_overtime", 0) + ws.cell(row=row_num, column=5).value = round(data.get("undertime_hours", 0), 2) + ws.cell(row=row_num, column=6).value = round(data["billable_hours"], 2) + ws.cell(row=row_num, column=7).value = data.get("days_with_overtime", 0) + ws.cell(row=row_num, column=8).value = data.get("days_under", 0) for col_num in range(1, len(headers) + 1): cell = ws.cell(row=row_num, column=col_num) diff --git a/app/routes/user.py b/app/routes/user.py index 7b66861..b6442d9 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -133,6 +133,8 @@ def settings(): else: flash(_("Standard hours per day must be between 0.5 and 24"), "error") return redirect(url_for("user.settings")) + if hasattr(current_user, "overtime_include_weekends"): + current_user.overtime_include_weekends = request.form.get("overtime_include_weekends") == "on" # Save changes if safe_commit(db.session): diff --git a/app/services/custom_report_service.py b/app/services/custom_report_service.py index 6439d95..bf6aa80 100644 --- a/app/services/custom_report_service.py +++ b/app/services/custom_report_service.py @@ -104,9 +104,9 @@ class CustomReportService: query = Expense.query if filters.get("start_date"): - query = query.filter(Expense.date >= filters["start_date"]) + query = query.filter(Expense.expense_date >= filters["start_date"]) if filters.get("end_date"): - query = query.filter(Expense.date <= filters["end_date"]) + query = query.filter(Expense.expense_date <= filters["end_date"]) expenses = query.all() diff --git a/app/static/dashboard-enhancements.js b/app/static/dashboard-enhancements.js index b6ace19..4907f44 100644 --- a/app/static/dashboard-enhancements.js +++ b/app/static/dashboard-enhancements.js @@ -337,6 +337,27 @@ if (data.month_hours !== undefined) { updateStatCard('monthHoursValue', data.month_hours); } + + // Update overtime lines (today and week) + const todayOvertimeEl = document.getElementById('todayOvertimeLine'); + if (todayOvertimeEl && data.standard_hours_per_day !== undefined) { + if (data.today_overtime_hours > 0) { + todayOvertimeEl.style.display = ''; + todayOvertimeEl.innerHTML = '+ ' + Number(data.today_overtime_hours).toFixed(2) + 'h overtime'; + } else { + todayOvertimeEl.innerHTML = '' + Number(data.today_hours).toFixed(2) + 'h / ' + Number(data.standard_hours_per_day).toFixed(1) + 'h'; + todayOvertimeEl.style.display = data.today_hours > 0 ? '' : 'none'; + } + } + const weekOvertimeEl = document.getElementById('weekOvertimeLine'); + if (weekOvertimeEl && data.week_overtime_hours !== undefined) { + if (data.week_overtime_hours > 0) { + weekOvertimeEl.style.display = ''; + weekOvertimeEl.innerHTML = '+ ' + Number(data.week_overtime_hours).toFixed(2) + 'h overtime'; + } else { + weekOvertimeEl.style.display = 'none'; + } + } } catch (error) { console.error('Error updating stats:', error); } diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 2c8a049..178ff18 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -95,6 +95,34 @@ + +
+ {{ _('Optionally require users to provide task and description when logging worked time. Enforced across web, mobile, and desktop.') }} +
+{{ _('Minimum number of characters required in the description field.') }}
+{{ _('Used as the standard hours per day for overtime calculation when new users are created. Existing users keep their own setting.') }}
+{{ _('Regular') }} / {{ _('Overtime') }}
+ +- Use the filters below to customize your CSV export. All filters are optional - leave blank to include all entries within the date range. + {{ _('Use the filters below to customize your export. Choose CSV or Excel format. All filters are optional - leave blank to include all entries within the date range.') }}
CSV Format:
+{{ _('CSV / Excel') }}
- The CSV file will be downloaded with a filename indicating the date range and applied filters. + {{ _('The file will be downloaded with a filename indicating the date range and applied filters.') }}
| {{ _('Date') }} | +{{ _('Start') }} | +{{ _('Stop') }} | +{{ _('Duration') }} | +{{ _('Project') }} | +{{ _('Task') }} | +{{ _('Notes') }} | +{{ _('Billed') }} | +{{ _('Client') }} | +
|---|---|---|---|---|---|---|---|---|
| {{ entry.start_time|user_date if entry.start_time else '-' }} | +{{ entry.start_time|user_datetime if entry.start_time else '-' }} | +{{ entry.end_time|user_datetime if entry.end_time else '-' }} | +{{ entry.duration_formatted if entry.end_time else '-' }} | +{{ entry.project.name if entry.project else '-' }} | +{{ entry.task.name if entry.task else '-' }} | +{{ entry.notes or '-' }} | +{{ _('Yes') if entry.paid else _('No') }} | ++ {% if entry.client %} + {{ entry.client.name }} + {% elif entry.project and entry.project.client_obj %} + {{ entry.project.client_obj.name }} + {% else %} + - + {% endif %} + | +
| {{ _('No time entries found for the selected filters.') }} | +||||||||
+ {{ _('When unchecked, any hours worked on Saturday or Sunday are always counted as overtime.') }} +
+