feat(reports): add Time Entries report with Excel/CSV export (Discussion #463)

- Add /reports/time-entries page listing all time entries (billed and unbilled)
- Columns: Date, Start, Stop, Duration, Project, Task, Notes, Billed, Client
- Filters: date range, user, project, client, task, billed (all/yes/no)
- Export to Excel and CSV with same filters; add Billed column to excel export
- Resolve client from entry.client or project.client in export
- Add Time Entries Report card to Reports index
This commit is contained in:
Dries Peeters
2026-02-13 20:56:07 +01:00
parent 7110194080
commit 54533ec95e
28 changed files with 1073 additions and 60 deletions
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+18
View File
@@ -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)
+5
View File
@@ -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
+23
View File
@@ -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
+10
View File
@@ -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
+43 -4
View File
@@ -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"],
}
)
+14
View File
@@ -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
+121 -1
View File
@@ -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": {}}
+13
View File
@@ -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,
+242 -5
View File
@@ -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)
+2
View File
@@ -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):
+2 -2
View File
@@ -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()
+21
View File
@@ -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 = '<span class="font-medium">+ ' + Number(data.today_overtime_hours).toFixed(2) + 'h overtime</span>';
} else {
todayOvertimeEl.innerHTML = '<span class="text-blue-600/70 dark:text-blue-400/70">' + Number(data.today_hours).toFixed(2) + 'h / ' + Number(data.standard_hours_per_day).toFixed(1) + 'h</span>';
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 = '<span class="font-medium">+ ' + Number(data.week_overtime_hours).toFixed(2) + 'h overtime</span>';
} else {
weekOvertimeEl.style.display = 'none';
}
}
} catch (error) {
console.error('Error updating stats:', error);
}
+37 -2
View File
@@ -95,6 +95,34 @@
</div>
</div>
<!-- Time Entry Requirements -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Time Entry Requirements') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Optionally require users to provide task and description when logging worked time. Enforced across web, mobile, and desktop.') }}
</p>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" name="time_entry_require_task" id="time_entry_require_task" {% if getattr(settings, 'time_entry_require_task', false) %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="time_entry_require_task" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Require task selection when logging time (project-based entries only)') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="time_entry_require_description" id="time_entry_require_description" {% if getattr(settings, 'time_entry_require_description', false) %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="time_entry_require_description" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Require description when logging time') }}</label>
</div>
<div id="time_entry_min_length_row" class="ml-6 {% if not getattr(settings, 'time_entry_require_description', false) %}hidden{% endif %}">
<label for="time_entry_description_min_length" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Minimum description length (characters)') }}</label>
<input type="number" name="time_entry_description_min_length" id="time_entry_description_min_length" value="{{ getattr(settings, 'time_entry_description_min_length', 20) }}" min="1" max="500" class="form-input w-32 mt-1">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Minimum number of characters required in the description field.') }}</p>
</div>
<div class="pt-4 border-t border-border-light dark:border-border-dark mt-4">
<label for="default_daily_working_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Default daily working hours (for new users)') }}</label>
<input type="number" name="default_daily_working_hours" id="default_daily_working_hours" value="{{ getattr(settings, 'default_daily_working_hours', 8.0) }}" min="0.5" max="24" step="0.5" class="form-input w-32 mt-1">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Used as the standard hours per day for overtime calculation when new users are created. Existing users keep their own setting.') }}</p>
</div>
</div>
</div>
<!-- User Management -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('User Management') }}</h2>
@@ -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);
});
}
});
</script>
{% endblock %}
+74
View File
@@ -70,6 +70,16 @@
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center h-100">
<div class="card-body">
<i class="fas fa-business-time fa-2x text-info mb-2"></i>
<h4 class="text-info" id="overtimeSummary">-</h4>
<p class="text-muted mb-0">{{ _('Regular') }} / {{ _('Overtime') }}</p>
<p class="text-muted small mb-0" id="overtimeDaysLabel"></p>
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
@@ -136,6 +146,24 @@
</div>
</div>
<!-- Overtime Chart -->
<div class="row mb-4">
<div class="col-12 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-business-time"></i> {{ _('Daily Regular vs Overtime') }}
</h5>
</div>
<div class="card-body">
<div class="chart-container" style="position: relative; height: 300px; width: 100%;">
<canvas id="overtimeChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="row mb-4">
<div class="col-lg-6 mb-4">
@@ -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}`);
+2 -2
View File
@@ -22,7 +22,7 @@
Start Date
</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
class="form-input">
</div>
<div class="flex-1 min-w-[200px]">
@@ -30,7 +30,7 @@
End Date
</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
class="form-input">
</div>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
+2 -2
View File
@@ -143,13 +143,13 @@
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
class="form-input">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
class="form-input">
</div>
<div>
+63 -3
View File
@@ -31,6 +31,15 @@
<span class="text-3xl font-bold text-blue-900 dark:text-blue-100 stat-value" id="todayHoursValue">{{ "%.2f"|format(today_hours) }}</span>
<span class="text-sm text-blue-600/70 dark:text-blue-400/70">hours</span>
</div>
{% if standard_hours_per_day is defined and today_overtime_hours is defined %}
<div id="todayOvertimeLine" class="mt-1 text-xs text-blue-700 dark:text-blue-300" {% if today_overtime_hours <= 0 %}style="display: none;"{% endif %}>
{% if today_overtime_hours > 0 %}
<span class="font-medium">+ {{ "%.2f"|format(today_overtime_hours) }}h {{ _('overtime') }}</span>
{% elif standard_hours_per_day %}
<span class="text-blue-600/70 dark:text-blue-400/70">{{ "%.2f"|format(today_hours) }}h / {{ "%.1f"|format(standard_hours_per_day) }}h</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="bg-blue-500/10 dark:bg-blue-400/10 p-4 rounded-full">
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-2xl"></i>
@@ -47,6 +56,11 @@
<span class="text-3xl font-bold text-green-900 dark:text-green-100 stat-value" id="weekHoursValue">{{ "%.2f"|format(week_hours) }}</span>
<span class="text-sm text-green-600/70 dark:text-green-400/70">hours</span>
</div>
{% if standard_hours_per_day is defined and week_overtime_hours is defined %}
<div id="weekOvertimeLine" class="mt-1 text-xs text-green-700 dark:text-green-300" {% if week_overtime_hours <= 0 %}style="display: none;"{% endif %}>
{% if week_overtime_hours > 0 %}<span class="font-medium">+ {{ "%.2f"|format(week_overtime_hours) }}h {{ _('overtime') }}</span>{% endif %}
</div>
{% endif %}
</div>
<div class="bg-green-500/10 dark:bg-green-400/10 p-4 rounded-full">
<i class="fas fa-calendar-week text-green-600 dark:text-green-400 text-2xl"></i>
@@ -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 }}">
<div class="absolute inset-0 bg-black/50" data-overlay></div>
<div class="relative max-w-lg mx-auto mt-24 max-h-[90vh] overflow-y-auto bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-lg" data-modal-content>
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between gap-2">
@@ -524,7 +544,7 @@
</div>
</div>
<div>
<label for="startTimerTask" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task (optional)') }}</label>
<label for="startTimerTask" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{% if getattr(settings, 'time_entry_require_task', false) %}{{ _('Task') }} *{% else %}{{ _('Task (optional)') }}{% endif %}</label>
<select id="startTimerTask" name="task_id" class="form-input w-full">
<option value="">{{ _('No task') }}</option>
<!-- Options populated dynamically when project is selected -->
@@ -534,7 +554,7 @@
</p>
</div>
<div>
<label for="startTimerNotes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Notes (optional)') }}</label>
<label for="startTimerNotes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if getattr(settings, 'time_entry_require_description', false) %}{{ _('Notes') }} *{% else %}{{ _('Notes (optional)') }}{% endif %}</label>
<div class="markdown-editor-wrapper">
<textarea id="startTimerNotes" name="notes" class="hidden" placeholder="{{ _('What are you working on?') }}"></textarea>
<div id="startTimerNotes_editor"></div>
@@ -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');
+41 -12
View File
@@ -6,7 +6,7 @@
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-file-export mr-2"></i>
Export Time Entries to CSV
{{ _('Export Time Entries') }}
</h1>
<a href="{{ url_for('reports.reports') }}" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition inline-block">
<i class="fas fa-arrow-left mr-2"></i>Back to Reports
@@ -21,7 +21,7 @@
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
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.') }}
</p>
</div>
</div>
@@ -30,6 +30,16 @@
<!-- Export Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow-lg">
<form id="exportForm" method="GET" action="{{ url_for('reports.export_csv') }}" class="space-y-6">
<!-- Format selector: same form supports CSV and Excel -->
<div>
<label for="export_format" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-file-alt mr-1"></i> {{ _('Export format') }}
</label>
<select id="export_format" class="form-input w-full max-w-xs" aria-label="{{ _('Export format') }}">
<option value="csv" {% if export_format == 'csv' %}selected{% endif %}>CSV</option>
<option value="excel" {% if export_format == 'excel' %}selected{% endif %}>Excel (.xlsx)</option>
</select>
</div>
<!-- Date Range Section -->
<div>
@@ -178,11 +188,12 @@
<button type="button"
id="resetBtn"
class="bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition">
<i class="fas fa-undo mr-2"></i>Reset Filters
<i class="fas fa-undo mr-2"></i>{{ _('Reset Filters') }}
</button>
<button type="submit"
id="exportSubmitBtn"
class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition">
<i class="fas fa-download mr-2"></i>Export to CSV
<i class="fas fa-download mr-2"></i><span id="exportSubmitLabel">{{ _('Export to CSV') if export_format == 'csv' else _('Export to Excel') }}</span>
</button>
</div>
</form>
@@ -195,7 +206,7 @@
Export Preview
</h3>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="mb-2"><strong>CSV Format:</strong></p>
<p class="mb-2"><strong>{{ _('CSV / Excel') }}</strong></p>
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded font-mono text-xs overflow-x-auto">
<div class="text-gray-700 dark:text-gray-300">
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 @@
</div>
<p class="mt-4 text-xs">
<i class="fas fa-info-circle mr-1"></i>
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.') }}
</p>
</div>
</div>
@@ -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();
});
</script>
+15 -10
View File
@@ -149,11 +149,11 @@
<div class="p-4 border border-border-light dark:border-border-dark rounded-lg">
<h3 class="font-medium mb-2">{{ _('Quick Actions') }}</h3>
<div class="space-y-2">
<a href="{{ url_for('reports.export_form') }}" class="block w-full bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-center">
<i class="fas fa-file-csv mr-2"></i>CSV Export
<a href="{{ url_for('reports.export_form', format='csv') }}" class="block w-full bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-center">
<i class="fas fa-file-csv mr-2"></i>{{ _('CSV Export') }}
</a>
<a href="{{ url_for('reports.export_excel') }}" class="block w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-center">
<i class="fas fa-file-excel mr-2"></i>Excel Export
<a href="{{ url_for('reports.export_form', format='excel') }}" class="block w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-center">
<i class="fas fa-file-excel mr-2"></i>{{ _('Excel Export') }}
</a>
</div>
</div>
@@ -175,6 +175,9 @@
<a href="{{ url_for('reports.task_report') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-tasks mr-2"></i>{{ _('Task Report') }}
</a>
<a href="{{ url_for('reports.time_entries_report') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-list-alt mr-2"></i>{{ _('Time Entries Report') }}
</a>
<a href="{{ url_for('reports.unpaid_hours_report') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-primary/90 transition-colors">
<i class="fas fa-money-bill-wave mr-2"></i>{{ _('Unpaid Hours Report') }}
</a>
@@ -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;
}
@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ _('Time Entries Report') }}</h1>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-calendar mr-1"></i>{{ _('Start Date') }}
</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="form-input">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-calendar mr-1"></i>{{ _('End Date') }}
</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="form-input">
</div>
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-user mr-1"></i>{{ _('User') }}
</label>
<select name="user_id" id="user_id" class="form-input">
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_user == user.id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-project-diagram mr-1"></i>{{ _('Project') }}
</label>
<select name="project_id" id="project_id" class="form-input">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-building mr-1"></i>{{ _('Client') }}
</label>
<select name="client_id" id="client_id" class="form-input">
<option value="">{{ _('All Clients') }}</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="task_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-tasks mr-1"></i>{{ _('Task') }}
</label>
<select name="task_id" id="task_id" class="form-input">
<option value="">{{ _('All Tasks') }}</option>
{% for task in tasks %}
<option value="{{ task.id }}" {% if selected_task == task.id %}selected{% endif %}>{{ task.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="billed" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fas fa-money-bill mr-1"></i>{{ _('Billed') }}
</label>
<select name="billed" id="billed" class="form-input">
<option value="all" {% if selected_billed == 'all' %}selected{% endif %}>{{ _('All') }}</option>
<option value="yes" {% if selected_billed == 'yes' %}selected{% endif %}>{{ _('Yes') }}</option>
<option value="no" {% if selected_billed == 'no' %}selected{% endif %}>{{ _('No') }}</option>
</select>
</div>
<div class="self-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition">
<i class="fas fa-filter mr-2"></i>{{ _('Filter') }}
</button>
</div>
</form>
<div class="mt-4 flex flex-wrap gap-2 items-center">
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ summary.entries_count }} {{ _('entries') }}, {{ "%.2f"|format(summary.total_hours) }} {{ _('hours') }}
</span>
<a href="{{ url_for('reports.time_entries_export_excel', start_date=start_date, end_date=end_date, user_id=selected_user or '', project_id=selected_project or '', client_id=selected_client or '', task_id=selected_task or '', billed=selected_billed) }}"
class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center text-sm">
<i class="fas fa-file-excel mr-2"></i>{{ _('Export to Excel') }}
</a>
<a href="{{ url_for('reports.time_entries_export_csv', start_date=start_date, end_date=end_date, user_id=selected_user or '', project_id=selected_project or '', client_id=selected_client or '', task_id=selected_task or '', billed=selected_billed) }}"
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center text-sm">
<i class="fas fa-file-csv mr-2"></i>{{ _('Export to CSV') }}
</a>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr>
<th class="p-2">{{ _('Date') }}</th>
<th class="p-2">{{ _('Start') }}</th>
<th class="p-2">{{ _('Stop') }}</th>
<th class="p-2">{{ _('Duration') }}</th>
<th class="p-2">{{ _('Project') }}</th>
<th class="p-2">{{ _('Task') }}</th>
<th class="p-2">{{ _('Notes') }}</th>
<th class="p-2">{{ _('Billed') }}</th>
<th class="p-2">{{ _('Client') }}</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-2">{{ entry.start_time|user_date if entry.start_time else '-' }}</td>
<td class="p-2">{{ entry.start_time|user_datetime if entry.start_time else '-' }}</td>
<td class="p-2">{{ entry.end_time|user_datetime if entry.end_time else '-' }}</td>
<td class="p-2">{{ entry.duration_formatted if entry.end_time else '-' }}</td>
<td class="p-2">{{ entry.project.name if entry.project else '-' }}</td>
<td class="p-2">{{ entry.task.name if entry.task else '-' }}</td>
<td class="p-2 max-w-xs truncate" title="{{ entry.notes or '' }}">{{ entry.notes or '-' }}</td>
<td class="p-2">{{ _('Yes') if entry.paid else _('No') }}</td>
<td class="p-2">
{% if entry.client %}
{{ entry.client.name }}
{% elif entry.project and entry.project.client_obj %}
{{ entry.project.client_obj.name }}
{% else %}
-
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No time entries found for the selected filters.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+9 -1
View File
@@ -107,6 +107,7 @@
<th class="p-2">Total Hours</th>
<th class="p-2">Regular Hours</th>
<th class="p-2">Overtime Hours</th>
<th class="p-2">Undertime Hours</th>
<th class="p-2">Billable Hours</th>
<th class="p-2">Days with Overtime</th>
</tr>
@@ -128,6 +129,13 @@
<span class="text-gray-400">0.00</span>
{% endif %}
</td>
<td class="p-2">
{% if totals.undertime_hours is defined and totals.undertime_hours > 0 %}
<span class="text-amber-600 dark:text-amber-400 font-medium">{{ "%.2f"|format(totals.undertime_hours) }}</span>
{% else %}
<span class="text-gray-400">0.00</span>
{% endif %}
</td>
<td class="p-2">{{ "%.2f"|format(totals.billable_hours) }}</td>
<td class="p-2 text-center">
{% if totals.days_with_overtime is defined and totals.days_with_overtime > 0 %}
@@ -141,7 +149,7 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="p-4 text-center">No data for the selected period.</td>
<td colspan="7" class="p-4 text-center">No data for the selected period.</td>
</tr>
{% endfor %}
</tbody>
+13
View File
@@ -235,6 +235,19 @@
</div>
</div>
</div>
<div class="mt-4">
<div class="flex items-center">
<input type="checkbox" id="overtime_include_weekends" name="overtime_include_weekends"
{% if getattr(user, 'overtime_include_weekends', true) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary dark:bg-gray-700 dark:border-gray-600">
<label for="overtime_include_weekends" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Count weekend hours in overtime') }}
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-6">
{{ _('When unchecked, any hours worked on Saturday or Sunday are always counted as overtime.') }}
</p>
</div>
</div>
<!-- Regional Settings -->
+6 -1
View File
@@ -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))),
+39 -13
View File
@@ -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"]),
}
)
@@ -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
@@ -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")