mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-12 15:29:23 -05:00
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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {}}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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")
|
||||
Reference in New Issue
Block a user