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 @@ + +
+

{{ _('Time Entry Requirements') }}

+

+ {{ _('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.') }}

+
+
+
+

{{ _('User Management') }}

@@ -543,7 +571,7 @@ async function verifyAdminDonateHideCode() { btn.disabled = false; } -// Initialize: keep collapsed by default +// Initialize: keep collapsed by default; toggle time entry min length visibility document.addEventListener('DOMContentLoaded', function() { const content = document.getElementById('integrationCredentialsContent'); const toggleIcon = document.getElementById('integrationCredentialsToggleIcon'); @@ -553,7 +581,14 @@ document.addEventListener('DOMContentLoaded', function() { toggleIcon.classList.remove('fa-chevron-up'); toggleIcon.classList.add('fa-chevron-down'); } - + + const descCheck = document.getElementById('time_entry_require_description'); + const minLenRow = document.getElementById('time_entry_min_length_row'); + if (descCheck && minLenRow) { + descCheck.addEventListener('change', function() { + minLenRow.classList.toggle('hidden', !descCheck.checked); + }); + } }); {% endblock %} diff --git a/app/templates/analytics/dashboard.html b/app/templates/analytics/dashboard.html index db5ab94..3fb0985 100644 --- a/app/templates/analytics/dashboard.html +++ b/app/templates/analytics/dashboard.html @@ -70,6 +70,16 @@
+
+
+
+ +

-

+

{{ _('Regular') }} / {{ _('Overtime') }}

+

+
+
+
@@ -136,6 +146,24 @@ + +
+
+
+
+
+ {{ _('Daily Regular vs Overtime') }} +
+
+
+
+ +
+
+
+
+
+
@@ -298,6 +326,7 @@ class AnalyticsDashboard { this.loadWeeklyTrendsChart(), this.loadHourlyChart(), this.loadEfficiencyChart(), + this.loadOvertimeChart(), {% if current_user.is_admin %} this.loadUserChart(), {% endif %} @@ -321,6 +350,7 @@ class AnalyticsDashboard { this.loadWeeklyTrendsChart(true), this.loadHourlyChart(true), this.loadEfficiencyChart(true), + this.loadOvertimeChart(true), {% if current_user.is_admin %} this.loadUserChart(true), {% endif %} @@ -553,6 +583,50 @@ class AnalyticsDashboard { }); } + async loadOvertimeChart(refresh = false) { + const response = await fetch(`/api/analytics/overtime?days=${this.timeRange}`); + const data = await response.json(); + if (!response.ok) return; + + const summary = data.summary || {}; + const summaryEl = document.getElementById('overtimeSummary'); + const daysLabelEl = document.getElementById('overtimeDaysLabel'); + if (summaryEl) { + summaryEl.textContent = (summary.total_regular_hours || 0).toFixed(1) + 'h / ' + (summary.total_overtime_hours || 0).toFixed(1) + 'h'; + } + if (daysLabelEl && data.users && data.users.length > 0) { + const daysWithOt = data.users.reduce((s, u) => s + (u.days_with_overtime || 0), 0); + daysLabelEl.textContent = daysWithOt > 0 ? (daysWithOt + ' ' + (i18n_analytics.days_overtime || 'days with overtime')) : ''; + } + + const daily = data.daily_breakdown || []; + if (refresh && this.charts.overtime) { + this.charts.overtime.destroy(); + } + const ctx = document.getElementById('overtimeChart'); + if (!ctx) return; + const chartData = { + labels: daily.map(d => d.date), + datasets: [ + { label: (i18n_analytics.regular_hours || 'Regular'), data: daily.map(d => d.regular_hours), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderColor: '#3b82f6', borderWidth: 1 }, + { label: (i18n_analytics.overtime_hours || 'Overtime'), data: daily.map(d => d.overtime_hours), backgroundColor: 'rgba(245, 158, 11, 0.8)', borderColor: '#f59e0b', borderWidth: 1 } + ] + }; + this.charts.overtime = new Chart(ctx.getContext('2d'), { + type: 'bar', + data: chartData, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom' } }, + scales: { + x: { stacked: true, ticks: { maxRotation: 45, minRotation: 45 } }, + y: { stacked: true, beginAtZero: true, title: { display: true, text: (i18n_analytics.hours_label || 'Hours') } } + } + } + }); + } + {% if current_user.is_admin %} async loadUserChart(refresh = false) { const response = await fetch(`/api/analytics/hours-by-user?days=${this.timeRange}`); diff --git a/app/templates/expenses/dashboard.html b/app/templates/expenses/dashboard.html index a4ff7a6..a6cd468 100644 --- a/app/templates/expenses/dashboard.html +++ b/app/templates/expenses/dashboard.html @@ -22,7 +22,7 @@ Start Date + class="form-input">
@@ -30,7 +30,7 @@ End Date + class="form-input">
@@ -47,6 +56,11 @@ {{ "%.2f"|format(week_hours) }} hours
+ {% if standard_hours_per_day is defined and week_overtime_hours is defined %} +
+ {% if week_overtime_hours > 0 %}+ {{ "%.2f"|format(week_overtime_hours) }}h {{ _('overtime') }}{% endif %} +
+ {% endif %}
@@ -492,7 +506,13 @@ data-error-title="{{ _('Error')|e }}" data-notes-placeholder="{{ _('What are you working on?')|e }}" data-only-one-client="{{ 'true' if only_one_client|default(false) else 'false' }}" - data-single-client-id="{{ single_client.id if single_client else '' }}"> + data-single-client-id="{{ single_client.id if single_client else '' }}" + data-require-task="{{ 'true' if getattr(settings, 'time_entry_require_task', false) else 'false' }}" + data-require-description="{{ 'true' if getattr(settings, 'time_entry_require_description', false) else 'false' }}" + data-description-min-length="{{ getattr(settings, 'time_entry_description_min_length', 20) }}" + data-task-required-msg="{{ _('A task must be selected when logging time for a project')|e }}" + data-description-required-msg="{{ _('A description is required when logging time')|e }}" + data-description-min-msg="{{ _('Description must be at least %(min)s characters', min=getattr(settings, 'time_entry_description_min_length', 20))|e }}">
@@ -524,7 +544,7 @@
- +
@@ -953,6 +973,46 @@ } return false; } + + // Validate time entry requirements (task, description) + const notesEl = document.getElementById('startTimerNotes'); + const requireTask = modal && modal.dataset.requireTask === 'true'; + const requireDescription = modal && modal.dataset.requireDescription === 'true'; + const descMinLen = modal ? parseInt(modal.dataset.descriptionMinLength || '20', 10) : 20; + if (projectVal && requireTask && !taskVal) { + const msg = modal?.dataset.taskRequiredMsg || 'A task must be selected when logging time for a project'; + if (window.toastManager && typeof window.toastManager.error === 'function') { + window.toastManager.error(msg, errorTitleText, 5000); + } else { + alert(msg); + } + return false; + } + if (requireDescription) { + let notesVal = notesEl ? notesEl.value : ''; + if (window.dashboardNotesEditor && typeof window.dashboardNotesEditor.getMarkdown === 'function') { + try { notesVal = window.dashboardNotesEditor.getMarkdown(); } catch (e) {} + } + const notesTrimmed = (notesVal || '').trim(); + if (!notesTrimmed) { + const msg = modal?.dataset.descriptionRequiredMsg || 'A description is required when logging time'; + if (window.toastManager && typeof window.toastManager.error === 'function') { + window.toastManager.error(msg, errorTitleText, 5000); + } else { + alert(msg); + } + return false; + } + if (notesTrimmed.length < descMinLen) { + const msg = (modal?.dataset.descriptionMinMsg || 'Description must be at least ' + descMinLen + ' characters').replace(/\d+/, String(descMinLen)); + if (window.toastManager && typeof window.toastManager.error === 'function') { + window.toastManager.error(msg, errorTitleText, 5000); + } else { + alert(msg); + } + return false; + } + } // Sync Toast UI Editor notes into hidden textarea (programmatic submit does not fire submit event) const notesEl = document.getElementById('startTimerNotes'); diff --git a/app/templates/reports/export_form.html b/app/templates/reports/export_form.html index 531e1ba..1465cdf 100644 --- a/app/templates/reports/export_form.html +++ b/app/templates/reports/export_form.html @@ -6,7 +6,7 @@

- Export Time Entries to CSV + {{ _('Export Time Entries') }}

Back to Reports @@ -21,7 +21,7 @@

- 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.') }}

@@ -30,6 +30,16 @@
+ +
+ + +
@@ -178,11 +188,12 @@
@@ -195,7 +206,7 @@ Export Preview
-

CSV Format:

+

{{ _('CSV / Excel') }}

ID, User, Project, Client, Task, Start Time, End Time, Duration (hours), Duration (formatted), Notes, Tags, Source, Billable, Created At, Updated At @@ -203,7 +214,7 @@

- 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.') }}

@@ -304,25 +315,43 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Form validation + // Set form action to CSV or Excel route based on format (both use same form params) + function setExportAction() { + const format = document.getElementById('export_format').value; + const csvUrl = '{{ url_for("reports.export_csv") }}'; + const excelUrl = '{{ url_for("reports.export_excel") }}'; + exportForm.action = format === 'excel' ? excelUrl : csvUrl; + } + + document.getElementById('export_format').addEventListener('change', function() { + setExportAction(); + const label = document.getElementById('exportSubmitLabel'); + label.textContent = this.value === 'excel' ? '{{ _("Export to Excel") }}' : '{{ _("Export to CSV") }}'; + }); + + // Form validation and set action on submit exportForm.addEventListener('submit', function(e) { + setExportAction(); + const startDate = document.getElementById('start_date').value; const endDate = document.getElementById('end_date').value; - + if (!startDate || !endDate) { e.preventDefault(); - alert('Please select both start and end dates.'); + alert('{{ _("Please select both start and end dates.") }}'); return false; } - + if (new Date(startDate) > new Date(endDate)) { e.preventDefault(); - alert('Start date must be before or equal to end date.'); + alert('{{ _("Start date must be before or equal to end date.") }}'); return false; } - + return true; }); + + setExportAction(); }); diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index 6288fa5..4733004 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -149,11 +149,11 @@
@@ -175,6 +175,9 @@ {{ _('Task Report') }} + + {{ _('Time Entries Report') }} + {{ _('Unpaid Hours Report') }} @@ -330,18 +333,20 @@ function exportReport() { const format = document.getElementById('exportFormat').value; const startDate = document.getElementById('startDate').value; const endDate = document.getElementById('endDate').value; - + + if (!startDate || !endDate) { + alert('{{ _("Please set a date range") }}'); + return; + } + let url = ''; if (format === 'csv') { url = `{{ url_for('reports.export_csv') }}`; } else if (format === 'excel') { url = `{{ url_for('reports.export_excel') }}`; } - - if (startDate && endDate) { - url += `?start_date=${startDate}&end_date=${endDate}`; - } - + + url += `?start_date=${startDate}&end_date=${endDate}`; window.location.href = url; } diff --git a/app/templates/reports/time_entries_report.html b/app/templates/reports/time_entries_report.html new file mode 100644 index 0000000..f4c1ab9 --- /dev/null +++ b/app/templates/reports/time_entries_report.html @@ -0,0 +1,142 @@ +{% extends "base.html" %} + +{% block content %} +
+

{{ _('Time Entries Report') }}

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + {{ summary.entries_count }} {{ _('entries') }}, {{ "%.2f"|format(summary.total_hours) }} {{ _('hours') }} + + + {{ _('Export to Excel') }} + + + {{ _('Export to CSV') }} + +
+
+ +
+ + + + + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('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.') }}
+
+{% endblock %} diff --git a/app/templates/reports/user_report.html b/app/templates/reports/user_report.html index 8dfb642..a5dc363 100644 --- a/app/templates/reports/user_report.html +++ b/app/templates/reports/user_report.html @@ -107,6 +107,7 @@ Total Hours Regular Hours Overtime Hours + Undertime Hours Billable Hours Days with Overtime @@ -128,6 +129,13 @@ 0.00 {% endif %} + + {% if totals.undertime_hours is defined and totals.undertime_hours > 0 %} + {{ "%.2f"|format(totals.undertime_hours) }} + {% else %} + 0.00 + {% endif %} + {{ "%.2f"|format(totals.billable_hours) }} {% if totals.days_with_overtime is defined and totals.days_with_overtime > 0 %} @@ -141,7 +149,7 @@ {% else %} - No data for the selected period. + No data for the selected period. {% endfor %} diff --git a/app/templates/user/settings.html b/app/templates/user/settings.html index c41273e..a2f604f 100644 --- a/app/templates/user/settings.html +++ b/app/templates/user/settings.html @@ -235,6 +235,19 @@
+
+
+ + +
+

+ {{ _('When unchecked, any hours worked on Saturday or Sunday are always counted as overtime.') }} +

+
diff --git a/app/utils/excel_export.py b/app/utils/excel_export.py index 4d8cbcb..7dbb74e 100644 --- a/app/utils/excel_export.py +++ b/app/utils/excel_export.py @@ -47,8 +47,13 @@ ALLOWED_TIME_ENTRY_EXPORT_COLUMNS = { "project": ("Project", lambda e: e.project.name if getattr(e, "project", None) else "N/A"), "client": ( "Client", - lambda e: (e.project.client if (getattr(e, "project", None) and getattr(e.project, "client", None)) else "N/A"), + lambda e: ( + (e.client.name if getattr(e, "client", None) else None) + or (e.project.client if (getattr(e, "project", None) and getattr(e.project, "client", None)) else None) + or "N/A" + ), ), + "billed": ("Billed", lambda e: "Yes" if getattr(e, "paid", False) else "No"), "task": ("Task", lambda e: e.task.name if getattr(e, "task", None) else "N/A"), "start_time": ("Start Time", lambda e: _safe_user_dt_iso(getattr(e, "start_time", None))), "end_time": ("End Time", lambda e: _safe_user_dt_iso(getattr(e, "end_time", None))), diff --git a/app/utils/overtime.py b/app/utils/overtime.py index f204180..f05a445 100644 --- a/app/utils/overtime.py +++ b/app/utils/overtime.py @@ -26,20 +26,23 @@ def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float def calculate_period_overtime( - user, start_date: date, end_date: date, include_weekends: bool = True + user, start_date: date, end_date: date, include_weekends: Optional[bool] = None ) -> Dict[str, float]: """ Calculate overtime for a specific period. Args: - user: User object with standard_hours_per_day setting + user: User object with standard_hours_per_day and optional overtime_include_weekends start_date: Start date of the period end_date: End date of the period - include_weekends: Whether to count weekend hours as overtime + include_weekends: If None, use user.overtime_include_weekends; if True, weekend hours + count as regular/overtime like weekdays; if False, all weekend hours count as overtime. Returns: - Dictionary with regular_hours, overtime_hours, and total_hours + Dictionary with regular_hours, overtime_hours, undertime_hours, and total_hours """ + if include_weekends is None: + include_weekends = getattr(user, "overtime_include_weekends", True) from app.models import TimeEntry from app import db @@ -67,10 +70,12 @@ def calculate_period_overtime( daily_hours[entry_date] = 0.0 daily_hours[entry_date] += hours - # Calculate overtime per day - standard_hours = user.standard_hours_per_day + # Calculate overtime and undertime per day + standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0 total_regular = 0.0 total_overtime = 0.0 + total_undertime = 0.0 + days_under = 0 for day_date, hours in daily_hours.items(): # Check if weekend @@ -78,9 +83,13 @@ def calculate_period_overtime( # All weekend hours are overtime total_overtime += hours else: - # Calculate regular vs overtime + # Calculate regular vs overtime vs undertime if hours <= standard_hours: total_regular += hours + undertime = max(0.0, standard_hours - hours) + if undertime > 0: + total_undertime += undertime + days_under += 1 else: total_regular += standard_hours total_overtime += hours - standard_hours @@ -88,23 +97,30 @@ def calculate_period_overtime( return { "regular_hours": round(total_regular, 2), "overtime_hours": round(total_overtime, 2), + "undertime_hours": round(total_undertime, 2), + "days_under": days_under, "total_hours": round(total_regular + total_overtime, 2), "days_with_overtime": sum(1 for h in daily_hours.values() if h > standard_hours), } -def get_daily_breakdown(user, start_date: date, end_date: date) -> List[Dict]: +def get_daily_breakdown( + user, start_date: date, end_date: date, include_weekends: Optional[bool] = None +) -> List[Dict]: """ Get a daily breakdown of regular and overtime hours. Args: - user: User object with standard_hours_per_day setting + user: User object with standard_hours_per_day and optional overtime_include_weekends start_date: Start date of the period end_date: End date of the period + include_weekends: If None, use user.overtime_include_weekends (see calculate_period_overtime). Returns: List of dictionaries with daily breakdown """ + if include_weekends is None: + include_weekends = getattr(user, "overtime_include_weekends", True) from app.models import TimeEntry from app import db @@ -137,16 +153,24 @@ def get_daily_breakdown(user, start_date: date, end_date: date) -> List[Dict]: daily_data[entry_date]["total_hours"] += entry.duration_hours daily_data[entry_date]["entries"].append(entry) - # Calculate overtime for each day - standard_hours = user.standard_hours_per_day + # Calculate overtime and undertime for each day + standard_hours = getattr(user, "standard_hours_per_day", 8.0) or 8.0 breakdown = [] for day_date in sorted(daily_data.keys()): day_info = daily_data[day_date] total_hours = day_info["total_hours"] - regular_hours = min(total_hours, standard_hours) - overtime_hours = max(0, total_hours - standard_hours) + # When include_weekends is False, weekend days count all hours as overtime + if not include_weekends and day_date.weekday() >= 5: + regular_hours = 0.0 + overtime_hours = total_hours + undertime_hours = 0.0 + else: + regular_hours = min(total_hours, standard_hours) + overtime_hours = max(0, total_hours - standard_hours) + undertime_hours = max(0, standard_hours - total_hours) if total_hours < standard_hours else 0.0 + is_undertime = undertime_hours > 0 breakdown.append( { @@ -156,7 +180,9 @@ def get_daily_breakdown(user, start_date: date, end_date: date) -> List[Dict]: "total_hours": round(total_hours, 2), "regular_hours": round(regular_hours, 2), "overtime_hours": round(overtime_hours, 2), + "undertime_hours": round(undertime_hours, 2), "is_overtime": overtime_hours > 0, + "is_undertime": is_undertime, "entries_count": len(day_info["entries"]), } ) diff --git a/migrations/versions/125_add_default_daily_working_hours.py b/migrations/versions/125_add_default_daily_working_hours.py new file mode 100644 index 0000000..943f235 --- /dev/null +++ b/migrations/versions/125_add_default_daily_working_hours.py @@ -0,0 +1,70 @@ +"""Add default_daily_working_hours to settings + +Revision ID: 125_add_default_daily_working_hours +Revises: 124_add_time_entry_requirements +Create Date: 2026-02-13 + +Admin-configurable default daily working hours for new users (overtime). +""" +from alembic import op +import sqlalchemy as sa + +revision = "125_add_default_daily_working_hours" +down_revision = "124_add_time_entry_requirements" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add default_daily_working_hours to settings""" + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + + existing_tables = inspector.get_table_names() + if "settings" not in existing_tables: + return + + settings_columns = {c["name"] for c in inspector.get_columns("settings")} + if "default_daily_working_hours" in settings_columns: + print("✓ Column default_daily_working_hours already exists in settings table") + return + + try: + op.add_column( + "settings", + sa.Column("default_daily_working_hours", sa.Float(), nullable=False, server_default="8.0"), + ) + print("✓ Added default_daily_working_hours column to settings table") + except Exception as e: + if "already exists" in str(e).lower() or "duplicate" in str(e).lower(): + print("✓ Column default_daily_working_hours already exists in settings table (detected via error)") + else: + raise + + +def downgrade(): + """Remove default_daily_working_hours from settings""" + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + + existing_tables = inspector.get_table_names() + if "settings" not in existing_tables: + return + + settings_columns = {c["name"] for c in inspector.get_columns("settings")} + if "default_daily_working_hours" not in settings_columns: + print("⊘ Column default_daily_working_hours does not exist in settings table, skipping") + return + + try: + op.drop_column("settings", "default_daily_working_hours") + print("✓ Dropped default_daily_working_hours column from settings table") + except Exception as e: + if "does not exist" in str(e).lower() or "no such column" in str(e).lower(): + print("⊘ Column default_daily_working_hours does not exist (detected via error)") + else: + raise diff --git a/migrations/versions/126_add_overtime_include_weekends_to_users.py b/migrations/versions/126_add_overtime_include_weekends_to_users.py new file mode 100644 index 0000000..b3265c4 --- /dev/null +++ b/migrations/versions/126_add_overtime_include_weekends_to_users.py @@ -0,0 +1,44 @@ +"""Add overtime_include_weekends to users + +Revision ID: 126_add_overtime_include_weekends +Revises: 125_add_default_daily_working_hours +Create Date: 2026-02-13 + +User preference: when False, weekend hours are always counted as overtime. +""" +from alembic import op +import sqlalchemy as sa + +revision = "126_add_overtime_include_weekends" +down_revision = "125_add_default_daily_working_hours" +branch_labels = None +depends_on = None + + +def upgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "users" not in inspector.get_table_names(): + return + cols = {c["name"] for c in inspector.get_columns("users")} + if "overtime_include_weekends" in cols: + return + op.add_column( + "users", + sa.Column("overtime_include_weekends", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade(): + from sqlalchemy import inspect + + bind = op.get_bind() + inspector = inspect(bind) + if "users" not in inspector.get_table_names(): + return + cols = {c["name"] for c in inspector.get_columns("users")} + if "overtime_include_weekends" not in cols: + return + op.drop_column("users", "overtime_include_weekends")